@sub-zero/cocoa 0.0.0-stage → 0.1.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 +164 -2
- package/dist/cocoa.js +330 -0
- package/dist/index.cjs +312 -0
- package/dist/index.d.cts +33 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +310 -0
- package/package.json +55 -4
package/README.md
CHANGED
|
@@ -1,3 +1,165 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Cocoa
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A bottom-right support chat bubble for any web application. Cocoa connects visitors
|
|
4
|
+
to your SubZero chatbot and puts conversations in **Agents → Chatbots**, where your
|
|
5
|
+
team can view, reply, approve agent drafts, and take over.
|
|
6
|
+
|
|
7
|
+
Zero runtime dependencies. Works with vanilla JavaScript, React, Vue, Angular,
|
|
8
|
+
and other browser frameworks. Styles live in a Shadow DOM so host styles do not
|
|
9
|
+
change the widget.
|
|
10
|
+
|
|
11
|
+
## Set up your chatbot
|
|
12
|
+
|
|
13
|
+
1. In SubZero, configure hosted Cocoa under **Agents → Cocoa**, or connect your
|
|
14
|
+
own agent. Hosted Cocoa is a $20/month add-on using your server-stored provider
|
|
15
|
+
key; your provider charges separately. Installing this UI does not start a
|
|
16
|
+
subscription or include a hosted agent.
|
|
17
|
+
2. Under **Agents → Chatbots**, create a chatbot for your project and agent.
|
|
18
|
+
3. Add your website's exact origin, including scheme and development port
|
|
19
|
+
(for example `https://app.example.com` or `http://localhost:3000`), and enable it.
|
|
20
|
+
4. Copy the chatbot ID from Settings & installation into the example below.
|
|
21
|
+
|
|
22
|
+
Only the **public chatbot ID** belongs in this widget. Never embed a model API
|
|
23
|
+
key, SubZero agent token, staff login token, or private RDK credential.
|
|
24
|
+
|
|
25
|
+
## Install with npm
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install @sub-zero/cocoa
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
import { createCocoa } from '@sub-zero/cocoa';
|
|
33
|
+
|
|
34
|
+
const cocoa = createCocoa({
|
|
35
|
+
chatbotId: 'YOUR_CHATBOT_UUID',
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Call `createCocoa` after the document body exists. The bubble appears immediately;
|
|
40
|
+
no conversation is created until the visitor sends a message. The default API is
|
|
41
|
+
`https://api.sub-zero.dev`.
|
|
42
|
+
|
|
43
|
+
### React / Next.js
|
|
44
|
+
|
|
45
|
+
Mount once in a client component or application layout. Importing the package is
|
|
46
|
+
safe on the server; mounting it requires a browser.
|
|
47
|
+
|
|
48
|
+
```jsx
|
|
49
|
+
'use client';
|
|
50
|
+
import { useEffect } from 'react';
|
|
51
|
+
import { createCocoa } from '@sub-zero/cocoa';
|
|
52
|
+
|
|
53
|
+
export function CocoaSupport() {
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const cocoa = createCocoa({ chatbotId: 'YOUR_CHATBOT_UUID' });
|
|
56
|
+
return () => cocoa.destroy();
|
|
57
|
+
}, []);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For Vue, mount in `onMounted` and destroy in `onUnmounted`. For Angular, mount in
|
|
63
|
+
`ngAfterViewInit` in the browser and destroy in `ngOnDestroy`. In an SPA, mount in
|
|
64
|
+
the application shell rather than on each route. For the same API/chatbot pair,
|
|
65
|
+
repeated calls return the existing instance; the first caller's options apply.
|
|
66
|
+
|
|
67
|
+
### Plain HTML
|
|
68
|
+
|
|
69
|
+
Copy `node_modules/@sub-zero/cocoa/dist/cocoa.js` to your public assets:
|
|
70
|
+
|
|
71
|
+
```html
|
|
72
|
+
<script defer src="/cocoa.js" data-chatbot="YOUR_CHATBOT_UUID"></script>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Optional attributes: `data-api`, `data-title`, `data-launcher-label`, and
|
|
76
|
+
`data-accent-color`. To initialize manually, load the script without
|
|
77
|
+
`data-chatbot` and call `window.Cocoa.createCocoa(options)`. Automatic initialization
|
|
78
|
+
exposes the handle as `window.Cocoa.widget`.
|
|
79
|
+
|
|
80
|
+
SubZero's dashboard-hosted `/subzero-chat.js` uses the same implementation and
|
|
81
|
+
continues to accept existing `data-api` and `data-chatbot` installation snippets.
|
|
82
|
+
For a reproducible release, self-host the file from a pinned npm version.
|
|
83
|
+
|
|
84
|
+
## Options and controls
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
const cocoa = createCocoa({
|
|
88
|
+
chatbotId: 'YOUR_CHATBOT_UUID',
|
|
89
|
+
apiUrl: 'https://api.sub-zero.dev',
|
|
90
|
+
title: 'Acme support',
|
|
91
|
+
launcherLabel: 'Need help?',
|
|
92
|
+
welcomeMessage: 'Hi! How can we help?',
|
|
93
|
+
accentColor: '#385b50',
|
|
94
|
+
visitorLabel: 'Alex', // display name only; not a verified identity
|
|
95
|
+
persistSession: true,
|
|
96
|
+
zIndex: 2147483000,
|
|
97
|
+
pollInterval: 2500,
|
|
98
|
+
// nonce: yourCspNonce,
|
|
99
|
+
onError(error) { console.warn(error.message); },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
cocoa.open();
|
|
103
|
+
cocoa.close();
|
|
104
|
+
cocoa.toggle();
|
|
105
|
+
cocoa.reset(); // forget this tab's conversation; does not delete server history
|
|
106
|
+
cocoa.destroy(); // remove UI, stop timers, abort requests; keep session for remount
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`reset()` aborts in-flight requests and clears local conversation state. A message
|
|
110
|
+
already accepted by the server cannot be recalled. Call it on application logout or
|
|
111
|
+
account switch to avoid resuming a previous visitor's conversation in that tab.
|
|
112
|
+
Use `persistSession: false` to keep credentials in memory only. With the default
|
|
113
|
+
setting, the visitor credential and any unacknowledged message retry are held in
|
|
114
|
+
sessionStorage. Blocked storage falls back to memory; private-browser reloads may
|
|
115
|
+
therefore start a new conversation. This package does not collect the current
|
|
116
|
+
page URL, app state, or account information.
|
|
117
|
+
|
|
118
|
+
The widget supports Escape to close, labeled form controls, polite announcement
|
|
119
|
+
of new messages, reduced motion, and narrow screens. It is a nonmodal dialog:
|
|
120
|
+
visitors can continue interacting with the host page. Replies are rendered as
|
|
121
|
+
plain text, never executable HTML or Markdown.
|
|
122
|
+
|
|
123
|
+
Polling runs only while the chat is open and the page is visible. Errors back off
|
|
124
|
+
up to 30 seconds; requests time out after 20 seconds. Message retries reuse an
|
|
125
|
+
idempotency key, including after reload when sessionStorage is available. Closing
|
|
126
|
+
or pausing a conversation disables sending; expired sessions offer a fresh start.
|
|
127
|
+
There is no substitute AI when the assigned agent is offline. Human replies
|
|
128
|
+
still arrive through the same conversation.
|
|
129
|
+
|
|
130
|
+
## Security and hosting
|
|
131
|
+
|
|
132
|
+
Use HTTPS for the API. HTTP is supported only for loopback development hosts.
|
|
133
|
+
Visitor tokens are sent only to the configured API, with cookies omitted and
|
|
134
|
+
redirects rejected. An allowed website origin controls embedding; it does not
|
|
135
|
+
verify the visitor's identity. Do not provide account-specific private data
|
|
136
|
+
based only on a name typed into chat.
|
|
137
|
+
|
|
138
|
+
For Content Security Policy, allow the API under `connect-src`, the script source
|
|
139
|
+
under `script-src`, and supply a valid nonce through the `nonce` option (or script
|
|
140
|
+
nonce attribute) for the widget stylesheet. The widget uses DOM APIs and is
|
|
141
|
+
compatible with Trusted Types enforcement. No remote fonts, images,
|
|
142
|
+
iframes, analytics, model SDKs, or third-party styles are loaded.
|
|
143
|
+
|
|
144
|
+
Existing SubZero chat webhooks and human approval policies apply unchanged. RDK
|
|
145
|
+
knowledge and provider credentials are handled by the assigned agent and backend,
|
|
146
|
+
never by this package.
|
|
147
|
+
|
|
148
|
+
## Maintainers
|
|
149
|
+
|
|
150
|
+
From the frontend repository:
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
npm run build:cocoa
|
|
154
|
+
npm run test:cocoa
|
|
155
|
+
cd packages/cocoa
|
|
156
|
+
npm pack --dry-run
|
|
157
|
+
npm publish --access public
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The package build uses only Node built-ins. Browser tests use the frontend's
|
|
161
|
+
Playwright development dependency and installed Chromium. The published tarball
|
|
162
|
+
contains only `dist`, this README, and package metadata. Do not publish the private
|
|
163
|
+
Angular application root. Review the version and `npm pack --dry-run` output before
|
|
164
|
+
publishing. No open-source license is granted (`UNLICENSED`); the source repository
|
|
165
|
+
may be private.
|
package/dist/cocoa.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/* @sub-zero/cocoa — generated by packages/cocoa/build.mjs */
|
|
2
|
+
(() => {
|
|
3
|
+
const instances = new Map();
|
|
4
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
|
|
6
|
+
function createCocoa(options = {}) {
|
|
7
|
+
if (typeof document === 'undefined' || !document.body) {
|
|
8
|
+
throw new Error('Mount Cocoa in the browser after the document body exists.');
|
|
9
|
+
}
|
|
10
|
+
if (!uuid.test(options.chatbotId || '')) throw new Error('A valid chatbotId is required.');
|
|
11
|
+
const url = new URL(options.apiUrl || 'https://api.sub-zero.dev');
|
|
12
|
+
if (url.username || url.password || url.search || url.hash ||
|
|
13
|
+
(url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
|
|
14
|
+
throw new Error('apiUrl must use HTTPS (HTTP is allowed only on loopback).');
|
|
15
|
+
}
|
|
16
|
+
const api = url.href.replace(/\/+$/, '');
|
|
17
|
+
const key = `subzero-chat:${api}:${options.chatbotId}`;
|
|
18
|
+
if (instances.has(key)) return instances.get(key);
|
|
19
|
+
const accent = options.accentColor || '#385b50';
|
|
20
|
+
if (!/^#[0-9a-f]{6}$/i.test(accent)) throw new Error('accentColor must be a six-digit hex color.');
|
|
21
|
+
const zIndex = options.zIndex ?? 2147483000;
|
|
22
|
+
if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
|
|
23
|
+
const pollInterval = options.pollInterval ?? 2500;
|
|
24
|
+
if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
|
|
25
|
+
const host = document.createElement('div');
|
|
26
|
+
host.dataset.cocoaWidget = '';
|
|
27
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
28
|
+
const style = document.createElement('style');
|
|
29
|
+
if (options.nonce) style.setAttribute('nonce', options.nonce);
|
|
30
|
+
style.textContent = `
|
|
31
|
+
:host{all:initial;position:fixed;right:max(16px,env(safe-area-inset-right));bottom:max(16px,env(safe-area-inset-bottom));z-index:${zIndex};font:14px/1.5 system-ui,sans-serif;color:#f2f5f8;color-scheme:dark;text-align:left}
|
|
32
|
+
*,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
|
|
33
|
+
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}
|
|
34
|
+
.launcher{display:block;margin-left:auto;background:${accent};border-color:#ffffff55;box-shadow:0 6px 24px #0004;font-weight:600;min-height:48px}
|
|
35
|
+
.panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 100px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
|
|
36
|
+
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}
|
|
37
|
+
.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}
|
|
38
|
+
.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}
|
|
39
|
+
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}
|
|
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)}}}
|
|
41
|
+
@media(max-height:540px){.panel{height:calc(100dvh - 96px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
|
|
42
|
+
`;
|
|
43
|
+
root.append(style);
|
|
44
|
+
// Use DOM APIs throughout, including on sites enforcing Trusted Types.
|
|
45
|
+
const element = (tag, attributes = {}, text) => {
|
|
46
|
+
const node = document.createElement(tag);
|
|
47
|
+
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
|
|
48
|
+
if (text !== undefined) node.textContent = text;
|
|
49
|
+
return node;
|
|
50
|
+
};
|
|
51
|
+
const panel = element('section', { class: 'panel', id: 'cocoa-panel', role: 'dialog', 'aria-label': options.title || 'Cocoa support', hidden: '' });
|
|
52
|
+
const header = element('header');
|
|
53
|
+
const heading = element('div');
|
|
54
|
+
heading.append(element('strong'), element('small', {}, 'Powered by SubZero'));
|
|
55
|
+
header.append(heading, element('button', { type: 'button', 'aria-label': 'Close support' }, '\u00d7'));
|
|
56
|
+
const composer = element('form');
|
|
57
|
+
const nameLabel = element('label', { class: 'name' }, 'Your name (optional)');
|
|
58
|
+
nameLabel.append(element('input', { maxlength: '120', autocomplete: 'given-name' }));
|
|
59
|
+
const messageLabel = element('label', {}, 'Your message');
|
|
60
|
+
messageLabel.append(element('textarea', { maxlength: '12000', required: '' }));
|
|
61
|
+
const actions = element('div', { class: 'actions' });
|
|
62
|
+
actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
|
|
63
|
+
composer.append(nameLabel, messageLabel, actions);
|
|
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
|
+
panel.querySelector('strong').textContent = options.title || 'Cocoa support';
|
|
66
|
+
const launcher = document.createElement('button');
|
|
67
|
+
launcher.type = 'button';
|
|
68
|
+
launcher.className = 'launcher';
|
|
69
|
+
launcher.textContent = options.launcherLabel || 'Chat with Cocoa';
|
|
70
|
+
launcher.setAttribute('aria-expanded', 'false');
|
|
71
|
+
launcher.setAttribute('aria-controls', panel.id);
|
|
72
|
+
launcher.setAttribute('aria-haspopup', 'dialog');
|
|
73
|
+
root.append(panel, launcher);
|
|
74
|
+
document.body.append(host);
|
|
75
|
+
const messages = panel.querySelector('.messages');
|
|
76
|
+
const status = panel.querySelector('.status');
|
|
77
|
+
const form = panel.querySelector('form');
|
|
78
|
+
const input = panel.querySelector('textarea');
|
|
79
|
+
const name = panel.querySelector('input');
|
|
80
|
+
const send = panel.querySelector('.send');
|
|
81
|
+
const fresh = panel.querySelector('.new');
|
|
82
|
+
name.value = String(options.visitorLabel || '').slice(0, 120);
|
|
83
|
+
let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
|
|
84
|
+
let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
|
|
85
|
+
const rendered = new Set();
|
|
86
|
+
const requests = new Set();
|
|
87
|
+
const persist = () => {
|
|
88
|
+
if (options.persistSession === false) return;
|
|
89
|
+
try {
|
|
90
|
+
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
|
|
91
|
+
else sessionStorage.removeItem(key);
|
|
92
|
+
} catch { /* Storage may be unavailable; an in-memory session still works. */ }
|
|
93
|
+
};
|
|
94
|
+
if (options.persistSession !== false) {
|
|
95
|
+
try {
|
|
96
|
+
const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
|
|
97
|
+
if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
|
|
98
|
+
session = { id: stored.id, token: stored.token };
|
|
99
|
+
if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
|
|
100
|
+
pending = stored.pending;
|
|
101
|
+
input.value = pending.body;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch { /* Ignore malformed storage. */ }
|
|
105
|
+
}
|
|
106
|
+
const welcome = () => {
|
|
107
|
+
const p = document.createElement('p');
|
|
108
|
+
p.className = 'welcome';
|
|
109
|
+
p.textContent = options.welcomeMessage || 'Hi! Tell us what you need help with. Cocoa and your support team can answer here.';
|
|
110
|
+
messages.replaceChildren(p);
|
|
111
|
+
};
|
|
112
|
+
welcome();
|
|
113
|
+
const controls = () => {
|
|
114
|
+
const focusWasInComposer = root.activeElement === input || root.activeElement === send;
|
|
115
|
+
input.disabled = sending || terminal || paused;
|
|
116
|
+
send.disabled = sending || terminal || paused;
|
|
117
|
+
name.disabled = sending;
|
|
118
|
+
fresh.hidden = !terminal;
|
|
119
|
+
panel.querySelector('.name').hidden = !!session;
|
|
120
|
+
send.textContent = sending ? 'Sending...' : 'Send';
|
|
121
|
+
if (focusWasInComposer && !sending && input.disabled && !panel.hidden) {
|
|
122
|
+
(terminal ? fresh : panel.querySelector('header button')).focus();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
controls();
|
|
126
|
+
const report = (error) => {
|
|
127
|
+
status.classList.add('error');
|
|
128
|
+
status.textContent = error.message;
|
|
129
|
+
try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
|
|
130
|
+
};
|
|
131
|
+
async function call(path, body, credential) {
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
requests.add(controller);
|
|
134
|
+
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(api + path, {
|
|
137
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
138
|
+
credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer',
|
|
139
|
+
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
140
|
+
...(credential ? { Authorization: `Bearer ${credential}` } : {}) },
|
|
141
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
});
|
|
144
|
+
if (!response.ok) {
|
|
145
|
+
const error = new Error(response.status === 401 ? 'This conversation has expired. Start a new conversation.' :
|
|
146
|
+
response.status === 429 ? 'Too many requests. Please wait a moment before trying again.' :
|
|
147
|
+
response.status === 404 ? 'Chat is unavailable. Please contact your support team.' :
|
|
148
|
+
response.status === 409 ? 'This conversation is closed or paused.' :
|
|
149
|
+
'Unable to connect. Your unsent message is kept here; please try again.');
|
|
150
|
+
error.status = response.status;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
return await response.json();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error.status) throw error;
|
|
156
|
+
throw new Error('Unable to connect. Your unsent message is kept here; please try again.');
|
|
157
|
+
} finally { clearTimeout(timeout); requests.delete(controller); }
|
|
158
|
+
}
|
|
159
|
+
function expire(error) {
|
|
160
|
+
if (error.status === 401 || error.status === 404) {
|
|
161
|
+
terminal = true;
|
|
162
|
+
session = null;
|
|
163
|
+
pending = null;
|
|
164
|
+
persist();
|
|
165
|
+
controls();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function paint(data) {
|
|
169
|
+
if (!Array.isArray(data.messages)) throw new Error('Chat returned an invalid response. Please try again.');
|
|
170
|
+
if (data.version !== version) {
|
|
171
|
+
const atBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 60;
|
|
172
|
+
if (data.messages.length) messages.querySelector('.welcome')?.remove();
|
|
173
|
+
for (const m of data.messages) {
|
|
174
|
+
if (rendered.has(m.id) || !['customer', 'agent', 'human'].includes(m.actor)) continue;
|
|
175
|
+
const item = document.createElement('div');
|
|
176
|
+
item.className = `message ${m.actor}`;
|
|
177
|
+
const label = document.createElement('small');
|
|
178
|
+
label.textContent = m.actor === 'customer' ? 'You' : m.actor === 'agent' ? (options.title || 'Cocoa') : 'Support team';
|
|
179
|
+
const text = document.createElement('span');
|
|
180
|
+
text.textContent = m.body;
|
|
181
|
+
item.append(label, text);
|
|
182
|
+
messages.append(item);
|
|
183
|
+
rendered.add(m.id);
|
|
184
|
+
}
|
|
185
|
+
if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
|
|
186
|
+
version = data.version;
|
|
187
|
+
}
|
|
188
|
+
terminal = data.state === 'closed';
|
|
189
|
+
paused = !data.enabled;
|
|
190
|
+
status.classList.remove('error');
|
|
191
|
+
status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
192
|
+
data.state === 'human' ? 'A person is handling your conversation.' :
|
|
193
|
+
data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
|
|
194
|
+
controls();
|
|
195
|
+
}
|
|
196
|
+
function schedule() {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
if (!destroyed && !panel.hidden && !document.hidden && session && !terminal) {
|
|
199
|
+
timer = setTimeout(refresh, Math.min(pollInterval * 2 ** failures, 30000));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function refresh() {
|
|
203
|
+
if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
|
|
204
|
+
refreshing = true;
|
|
205
|
+
const current = generation;
|
|
206
|
+
try {
|
|
207
|
+
const data = await call(`/chat/sessions/${session.id}`, undefined, session.token);
|
|
208
|
+
if (destroyed || current !== generation) return;
|
|
209
|
+
paint(data);
|
|
210
|
+
failures = 0;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (!destroyed && current === generation) { failures = Math.min(failures + 1, 4); expire(error); report(error); }
|
|
213
|
+
} finally {
|
|
214
|
+
if (current === generation) { refreshing = false; schedule(); }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function close() {
|
|
218
|
+
if (destroyed) return;
|
|
219
|
+
panel.hidden = true;
|
|
220
|
+
launcher.setAttribute('aria-expanded', 'false');
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
launcher.focus();
|
|
223
|
+
}
|
|
224
|
+
function open() {
|
|
225
|
+
if (destroyed) return;
|
|
226
|
+
panel.hidden = false;
|
|
227
|
+
launcher.setAttribute('aria-expanded', 'true');
|
|
228
|
+
(terminal ? fresh : input).focus();
|
|
229
|
+
void refresh();
|
|
230
|
+
}
|
|
231
|
+
function reset() {
|
|
232
|
+
if (destroyed) return;
|
|
233
|
+
generation++;
|
|
234
|
+
sending = refreshing = false;
|
|
235
|
+
for (const request of requests) request.abort();
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
session = pending = null;
|
|
238
|
+
terminal = paused = false;
|
|
239
|
+
version = -1;
|
|
240
|
+
failures = 0;
|
|
241
|
+
rendered.clear();
|
|
242
|
+
persist();
|
|
243
|
+
welcome();
|
|
244
|
+
input.value = '';
|
|
245
|
+
status.textContent = '';
|
|
246
|
+
status.classList.remove('error');
|
|
247
|
+
controls();
|
|
248
|
+
if (!panel.hidden) input.focus();
|
|
249
|
+
}
|
|
250
|
+
const visibility = () => {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
if (!document.hidden) void refresh();
|
|
253
|
+
};
|
|
254
|
+
document.addEventListener('visibilitychange', visibility);
|
|
255
|
+
const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
|
|
256
|
+
if (destroyed) return;
|
|
257
|
+
destroyed = true;
|
|
258
|
+
generation++;
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
for (const request of requests) request.abort();
|
|
261
|
+
document.removeEventListener('visibilitychange', visibility);
|
|
262
|
+
host.remove();
|
|
263
|
+
instances.delete(key);
|
|
264
|
+
} };
|
|
265
|
+
launcher.onclick = widget.toggle;
|
|
266
|
+
panel.querySelector('header button').onclick = close;
|
|
267
|
+
fresh.onclick = reset;
|
|
268
|
+
root.addEventListener('keydown', (event) => {
|
|
269
|
+
if (event.key === 'Escape' && !panel.hidden) { event.preventDefault(); close(); }
|
|
270
|
+
});
|
|
271
|
+
form.onsubmit = async (event) => {
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
const body = input.value.trim();
|
|
274
|
+
if (!body || sending || terminal || paused || destroyed) return;
|
|
275
|
+
sending = true;
|
|
276
|
+
controls();
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
const current = generation;
|
|
279
|
+
try {
|
|
280
|
+
if (!session) {
|
|
281
|
+
const result = await call(`/chat/${options.chatbotId}/sessions`, { label: name.value.trim() || 'Visitor' });
|
|
282
|
+
if (destroyed || current !== generation) return;
|
|
283
|
+
if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
|
|
284
|
+
session = { id: result.id, token: result.token };
|
|
285
|
+
persist();
|
|
286
|
+
}
|
|
287
|
+
if (!pending || pending.body !== body) pending = { body, idempotencyKey: crypto.randomUUID() };
|
|
288
|
+
persist();
|
|
289
|
+
await call(`/chat/sessions/${session.id}/messages`, pending, session.token);
|
|
290
|
+
if (destroyed || current !== generation) return;
|
|
291
|
+
pending = null;
|
|
292
|
+
persist();
|
|
293
|
+
input.value = '';
|
|
294
|
+
failures = 0;
|
|
295
|
+
status.classList.remove('error');
|
|
296
|
+
status.textContent = 'Message sent.';
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (!destroyed && current === generation) { expire(error); report(error); }
|
|
299
|
+
} finally {
|
|
300
|
+
if (!destroyed && current === generation) {
|
|
301
|
+
sending = false;
|
|
302
|
+
controls();
|
|
303
|
+
// Keep send failures visible until the visitor retries; polling resumes later.
|
|
304
|
+
if (!pending && !terminal) await refresh();
|
|
305
|
+
else schedule();
|
|
306
|
+
if (!panel.hidden && !input.disabled) input.focus();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
instances.set(key, widget);
|
|
311
|
+
return widget;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const script = document.currentScript;
|
|
315
|
+
window.Cocoa = { ...(window.Cocoa || {}), createCocoa };
|
|
316
|
+
if (script?.dataset.chatbot) {
|
|
317
|
+
const options = {
|
|
318
|
+
chatbotId: script.dataset.chatbot,
|
|
319
|
+
apiUrl: script.dataset.api,
|
|
320
|
+
title: script.dataset.title,
|
|
321
|
+
launcherLabel: script.dataset.launcherLabel,
|
|
322
|
+
accentColor: script.dataset.accentColor,
|
|
323
|
+
nonce: script.nonce,
|
|
324
|
+
};
|
|
325
|
+
const mount = () => { window.Cocoa.widget = createCocoa(options); };
|
|
326
|
+
if (document.body) mount();
|
|
327
|
+
else document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
})();
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
const instances = new Map();
|
|
2
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
+
|
|
4
|
+
function createCocoa(options = {}) {
|
|
5
|
+
if (typeof document === 'undefined' || !document.body) {
|
|
6
|
+
throw new Error('Mount Cocoa in the browser after the document body exists.');
|
|
7
|
+
}
|
|
8
|
+
if (!uuid.test(options.chatbotId || '')) throw new Error('A valid chatbotId is required.');
|
|
9
|
+
const url = new URL(options.apiUrl || 'https://api.sub-zero.dev');
|
|
10
|
+
if (url.username || url.password || url.search || url.hash ||
|
|
11
|
+
(url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
|
|
12
|
+
throw new Error('apiUrl must use HTTPS (HTTP is allowed only on loopback).');
|
|
13
|
+
}
|
|
14
|
+
const api = url.href.replace(/\/+$/, '');
|
|
15
|
+
const key = `subzero-chat:${api}:${options.chatbotId}`;
|
|
16
|
+
if (instances.has(key)) return instances.get(key);
|
|
17
|
+
const accent = options.accentColor || '#385b50';
|
|
18
|
+
if (!/^#[0-9a-f]{6}$/i.test(accent)) throw new Error('accentColor must be a six-digit hex color.');
|
|
19
|
+
const zIndex = options.zIndex ?? 2147483000;
|
|
20
|
+
if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
|
|
21
|
+
const pollInterval = options.pollInterval ?? 2500;
|
|
22
|
+
if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
|
|
23
|
+
const host = document.createElement('div');
|
|
24
|
+
host.dataset.cocoaWidget = '';
|
|
25
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
26
|
+
const style = document.createElement('style');
|
|
27
|
+
if (options.nonce) style.setAttribute('nonce', options.nonce);
|
|
28
|
+
style.textContent = `
|
|
29
|
+
:host{all:initial;position:fixed;right:max(16px,env(safe-area-inset-right));bottom:max(16px,env(safe-area-inset-bottom));z-index:${zIndex};font:14px/1.5 system-ui,sans-serif;color:#f2f5f8;color-scheme:dark;text-align:left}
|
|
30
|
+
*,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
|
|
31
|
+
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}
|
|
32
|
+
.launcher{display:block;margin-left:auto;background:${accent};border-color:#ffffff55;box-shadow:0 6px 24px #0004;font-weight:600;min-height:48px}
|
|
33
|
+
.panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 100px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
|
|
34
|
+
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}
|
|
35
|
+
.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}
|
|
36
|
+
.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}
|
|
37
|
+
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}
|
|
38
|
+
@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)}}}
|
|
39
|
+
@media(max-height:540px){.panel{height:calc(100dvh - 96px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
|
|
40
|
+
`;
|
|
41
|
+
root.append(style);
|
|
42
|
+
// Use DOM APIs throughout, including on sites enforcing Trusted Types.
|
|
43
|
+
const element = (tag, attributes = {}, text) => {
|
|
44
|
+
const node = document.createElement(tag);
|
|
45
|
+
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
|
|
46
|
+
if (text !== undefined) node.textContent = text;
|
|
47
|
+
return node;
|
|
48
|
+
};
|
|
49
|
+
const panel = element('section', { class: 'panel', id: 'cocoa-panel', role: 'dialog', 'aria-label': options.title || 'Cocoa support', hidden: '' });
|
|
50
|
+
const header = element('header');
|
|
51
|
+
const heading = element('div');
|
|
52
|
+
heading.append(element('strong'), element('small', {}, 'Powered by SubZero'));
|
|
53
|
+
header.append(heading, element('button', { type: 'button', 'aria-label': 'Close support' }, '\u00d7'));
|
|
54
|
+
const composer = element('form');
|
|
55
|
+
const nameLabel = element('label', { class: 'name' }, 'Your name (optional)');
|
|
56
|
+
nameLabel.append(element('input', { maxlength: '120', autocomplete: 'given-name' }));
|
|
57
|
+
const messageLabel = element('label', {}, 'Your message');
|
|
58
|
+
messageLabel.append(element('textarea', { maxlength: '12000', required: '' }));
|
|
59
|
+
const actions = element('div', { class: 'actions' });
|
|
60
|
+
actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
|
|
61
|
+
composer.append(nameLabel, messageLabel, actions);
|
|
62
|
+
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);
|
|
63
|
+
panel.querySelector('strong').textContent = options.title || 'Cocoa support';
|
|
64
|
+
const launcher = document.createElement('button');
|
|
65
|
+
launcher.type = 'button';
|
|
66
|
+
launcher.className = 'launcher';
|
|
67
|
+
launcher.textContent = options.launcherLabel || 'Chat with Cocoa';
|
|
68
|
+
launcher.setAttribute('aria-expanded', 'false');
|
|
69
|
+
launcher.setAttribute('aria-controls', panel.id);
|
|
70
|
+
launcher.setAttribute('aria-haspopup', 'dialog');
|
|
71
|
+
root.append(panel, launcher);
|
|
72
|
+
document.body.append(host);
|
|
73
|
+
const messages = panel.querySelector('.messages');
|
|
74
|
+
const status = panel.querySelector('.status');
|
|
75
|
+
const form = panel.querySelector('form');
|
|
76
|
+
const input = panel.querySelector('textarea');
|
|
77
|
+
const name = panel.querySelector('input');
|
|
78
|
+
const send = panel.querySelector('.send');
|
|
79
|
+
const fresh = panel.querySelector('.new');
|
|
80
|
+
name.value = String(options.visitorLabel || '').slice(0, 120);
|
|
81
|
+
let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
|
|
82
|
+
let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
|
|
83
|
+
const rendered = new Set();
|
|
84
|
+
const requests = new Set();
|
|
85
|
+
const persist = () => {
|
|
86
|
+
if (options.persistSession === false) return;
|
|
87
|
+
try {
|
|
88
|
+
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
|
|
89
|
+
else sessionStorage.removeItem(key);
|
|
90
|
+
} catch { /* Storage may be unavailable; an in-memory session still works. */ }
|
|
91
|
+
};
|
|
92
|
+
if (options.persistSession !== false) {
|
|
93
|
+
try {
|
|
94
|
+
const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
|
|
95
|
+
if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
|
|
96
|
+
session = { id: stored.id, token: stored.token };
|
|
97
|
+
if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
|
|
98
|
+
pending = stored.pending;
|
|
99
|
+
input.value = pending.body;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch { /* Ignore malformed storage. */ }
|
|
103
|
+
}
|
|
104
|
+
const welcome = () => {
|
|
105
|
+
const p = document.createElement('p');
|
|
106
|
+
p.className = 'welcome';
|
|
107
|
+
p.textContent = options.welcomeMessage || 'Hi! Tell us what you need help with. Cocoa and your support team can answer here.';
|
|
108
|
+
messages.replaceChildren(p);
|
|
109
|
+
};
|
|
110
|
+
welcome();
|
|
111
|
+
const controls = () => {
|
|
112
|
+
const focusWasInComposer = root.activeElement === input || root.activeElement === send;
|
|
113
|
+
input.disabled = sending || terminal || paused;
|
|
114
|
+
send.disabled = sending || terminal || paused;
|
|
115
|
+
name.disabled = sending;
|
|
116
|
+
fresh.hidden = !terminal;
|
|
117
|
+
panel.querySelector('.name').hidden = !!session;
|
|
118
|
+
send.textContent = sending ? 'Sending...' : 'Send';
|
|
119
|
+
if (focusWasInComposer && !sending && input.disabled && !panel.hidden) {
|
|
120
|
+
(terminal ? fresh : panel.querySelector('header button')).focus();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
controls();
|
|
124
|
+
const report = (error) => {
|
|
125
|
+
status.classList.add('error');
|
|
126
|
+
status.textContent = error.message;
|
|
127
|
+
try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
|
|
128
|
+
};
|
|
129
|
+
async function call(path, body, credential) {
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
requests.add(controller);
|
|
132
|
+
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetch(api + path, {
|
|
135
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
136
|
+
credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer',
|
|
137
|
+
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
138
|
+
...(credential ? { Authorization: `Bearer ${credential}` } : {}) },
|
|
139
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
140
|
+
signal: controller.signal,
|
|
141
|
+
});
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const error = new Error(response.status === 401 ? 'This conversation has expired. Start a new conversation.' :
|
|
144
|
+
response.status === 429 ? 'Too many requests. Please wait a moment before trying again.' :
|
|
145
|
+
response.status === 404 ? 'Chat is unavailable. Please contact your support team.' :
|
|
146
|
+
response.status === 409 ? 'This conversation is closed or paused.' :
|
|
147
|
+
'Unable to connect. Your unsent message is kept here; please try again.');
|
|
148
|
+
error.status = response.status;
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
return await response.json();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error.status) throw error;
|
|
154
|
+
throw new Error('Unable to connect. Your unsent message is kept here; please try again.');
|
|
155
|
+
} finally { clearTimeout(timeout); requests.delete(controller); }
|
|
156
|
+
}
|
|
157
|
+
function expire(error) {
|
|
158
|
+
if (error.status === 401 || error.status === 404) {
|
|
159
|
+
terminal = true;
|
|
160
|
+
session = null;
|
|
161
|
+
pending = null;
|
|
162
|
+
persist();
|
|
163
|
+
controls();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function paint(data) {
|
|
167
|
+
if (!Array.isArray(data.messages)) throw new Error('Chat returned an invalid response. Please try again.');
|
|
168
|
+
if (data.version !== version) {
|
|
169
|
+
const atBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 60;
|
|
170
|
+
if (data.messages.length) messages.querySelector('.welcome')?.remove();
|
|
171
|
+
for (const m of data.messages) {
|
|
172
|
+
if (rendered.has(m.id) || !['customer', 'agent', 'human'].includes(m.actor)) continue;
|
|
173
|
+
const item = document.createElement('div');
|
|
174
|
+
item.className = `message ${m.actor}`;
|
|
175
|
+
const label = document.createElement('small');
|
|
176
|
+
label.textContent = m.actor === 'customer' ? 'You' : m.actor === 'agent' ? (options.title || 'Cocoa') : 'Support team';
|
|
177
|
+
const text = document.createElement('span');
|
|
178
|
+
text.textContent = m.body;
|
|
179
|
+
item.append(label, text);
|
|
180
|
+
messages.append(item);
|
|
181
|
+
rendered.add(m.id);
|
|
182
|
+
}
|
|
183
|
+
if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
|
|
184
|
+
version = data.version;
|
|
185
|
+
}
|
|
186
|
+
terminal = data.state === 'closed';
|
|
187
|
+
paused = !data.enabled;
|
|
188
|
+
status.classList.remove('error');
|
|
189
|
+
status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
190
|
+
data.state === 'human' ? 'A person is handling your conversation.' :
|
|
191
|
+
data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
|
|
192
|
+
controls();
|
|
193
|
+
}
|
|
194
|
+
function schedule() {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
if (!destroyed && !panel.hidden && !document.hidden && session && !terminal) {
|
|
197
|
+
timer = setTimeout(refresh, Math.min(pollInterval * 2 ** failures, 30000));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function refresh() {
|
|
201
|
+
if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
|
|
202
|
+
refreshing = true;
|
|
203
|
+
const current = generation;
|
|
204
|
+
try {
|
|
205
|
+
const data = await call(`/chat/sessions/${session.id}`, undefined, session.token);
|
|
206
|
+
if (destroyed || current !== generation) return;
|
|
207
|
+
paint(data);
|
|
208
|
+
failures = 0;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (!destroyed && current === generation) { failures = Math.min(failures + 1, 4); expire(error); report(error); }
|
|
211
|
+
} finally {
|
|
212
|
+
if (current === generation) { refreshing = false; schedule(); }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function close() {
|
|
216
|
+
if (destroyed) return;
|
|
217
|
+
panel.hidden = true;
|
|
218
|
+
launcher.setAttribute('aria-expanded', 'false');
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
launcher.focus();
|
|
221
|
+
}
|
|
222
|
+
function open() {
|
|
223
|
+
if (destroyed) return;
|
|
224
|
+
panel.hidden = false;
|
|
225
|
+
launcher.setAttribute('aria-expanded', 'true');
|
|
226
|
+
(terminal ? fresh : input).focus();
|
|
227
|
+
void refresh();
|
|
228
|
+
}
|
|
229
|
+
function reset() {
|
|
230
|
+
if (destroyed) return;
|
|
231
|
+
generation++;
|
|
232
|
+
sending = refreshing = false;
|
|
233
|
+
for (const request of requests) request.abort();
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
session = pending = null;
|
|
236
|
+
terminal = paused = false;
|
|
237
|
+
version = -1;
|
|
238
|
+
failures = 0;
|
|
239
|
+
rendered.clear();
|
|
240
|
+
persist();
|
|
241
|
+
welcome();
|
|
242
|
+
input.value = '';
|
|
243
|
+
status.textContent = '';
|
|
244
|
+
status.classList.remove('error');
|
|
245
|
+
controls();
|
|
246
|
+
if (!panel.hidden) input.focus();
|
|
247
|
+
}
|
|
248
|
+
const visibility = () => {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
if (!document.hidden) void refresh();
|
|
251
|
+
};
|
|
252
|
+
document.addEventListener('visibilitychange', visibility);
|
|
253
|
+
const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
|
|
254
|
+
if (destroyed) return;
|
|
255
|
+
destroyed = true;
|
|
256
|
+
generation++;
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
for (const request of requests) request.abort();
|
|
259
|
+
document.removeEventListener('visibilitychange', visibility);
|
|
260
|
+
host.remove();
|
|
261
|
+
instances.delete(key);
|
|
262
|
+
} };
|
|
263
|
+
launcher.onclick = widget.toggle;
|
|
264
|
+
panel.querySelector('header button').onclick = close;
|
|
265
|
+
fresh.onclick = reset;
|
|
266
|
+
root.addEventListener('keydown', (event) => {
|
|
267
|
+
if (event.key === 'Escape' && !panel.hidden) { event.preventDefault(); close(); }
|
|
268
|
+
});
|
|
269
|
+
form.onsubmit = async (event) => {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
const body = input.value.trim();
|
|
272
|
+
if (!body || sending || terminal || paused || destroyed) return;
|
|
273
|
+
sending = true;
|
|
274
|
+
controls();
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
const current = generation;
|
|
277
|
+
try {
|
|
278
|
+
if (!session) {
|
|
279
|
+
const result = await call(`/chat/${options.chatbotId}/sessions`, { label: name.value.trim() || 'Visitor' });
|
|
280
|
+
if (destroyed || current !== generation) return;
|
|
281
|
+
if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
|
|
282
|
+
session = { id: result.id, token: result.token };
|
|
283
|
+
persist();
|
|
284
|
+
}
|
|
285
|
+
if (!pending || pending.body !== body) pending = { body, idempotencyKey: crypto.randomUUID() };
|
|
286
|
+
persist();
|
|
287
|
+
await call(`/chat/sessions/${session.id}/messages`, pending, session.token);
|
|
288
|
+
if (destroyed || current !== generation) return;
|
|
289
|
+
pending = null;
|
|
290
|
+
persist();
|
|
291
|
+
input.value = '';
|
|
292
|
+
failures = 0;
|
|
293
|
+
status.classList.remove('error');
|
|
294
|
+
status.textContent = 'Message sent.';
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (!destroyed && current === generation) { expire(error); report(error); }
|
|
297
|
+
} finally {
|
|
298
|
+
if (!destroyed && current === generation) {
|
|
299
|
+
sending = false;
|
|
300
|
+
controls();
|
|
301
|
+
// Keep send failures visible until the visitor retries; polling resumes later.
|
|
302
|
+
if (!pending && !terminal) await refresh();
|
|
303
|
+
else schedule();
|
|
304
|
+
if (!panel.hidden && !input.disabled) input.focus();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
instances.set(key, widget);
|
|
309
|
+
return widget;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = { createCocoa };
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface CocoaOptions {
|
|
2
|
+
/** Public deployment UUID from SubZero → Agents → Chatbots. */
|
|
3
|
+
chatbotId: string;
|
|
4
|
+
/** Defaults to https://api.sub-zero.dev. HTTPS required except on loopback. */
|
|
5
|
+
apiUrl?: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
launcherLabel?: string;
|
|
8
|
+
welcomeMessage?: string;
|
|
9
|
+
/** Unverified display name, never an authentication credential. */
|
|
10
|
+
visitorLabel?: string;
|
|
11
|
+
/** Six-digit hex color, e.g. #385b50. */
|
|
12
|
+
accentColor?: string;
|
|
13
|
+
zIndex?: number;
|
|
14
|
+
/** Defaults to true. Stores the visitor credential in this tab's sessionStorage. */
|
|
15
|
+
persistSession?: boolean;
|
|
16
|
+
/** CSP nonce for the widget's isolated stylesheet. */
|
|
17
|
+
nonce?: string;
|
|
18
|
+
/** Minimum 2500ms; defaults to 2500. Only polls while visible. */
|
|
19
|
+
pollInterval?: number;
|
|
20
|
+
/** Receives sanitized errors; no visitor credentials or transcript. */
|
|
21
|
+
onError?: (error: Error) => void;
|
|
22
|
+
}
|
|
23
|
+
export interface CocoaWidget {
|
|
24
|
+
open(): void;
|
|
25
|
+
close(): void;
|
|
26
|
+
toggle(): void;
|
|
27
|
+
/** Forget this tab's session; does not delete its server transcript. */
|
|
28
|
+
reset(): void;
|
|
29
|
+
/** Remove UI, timers, listeners and abort in-flight requests. */
|
|
30
|
+
destroy(): void;
|
|
31
|
+
}
|
|
32
|
+
/** Call after the document body exists. Importing is safe during SSR. */
|
|
33
|
+
export function createCocoa(options: CocoaOptions): CocoaWidget;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface CocoaOptions {
|
|
2
|
+
/** Public deployment UUID from SubZero → Agents → Chatbots. */
|
|
3
|
+
chatbotId: string;
|
|
4
|
+
/** Defaults to https://api.sub-zero.dev. HTTPS required except on loopback. */
|
|
5
|
+
apiUrl?: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
launcherLabel?: string;
|
|
8
|
+
welcomeMessage?: string;
|
|
9
|
+
/** Unverified display name, never an authentication credential. */
|
|
10
|
+
visitorLabel?: string;
|
|
11
|
+
/** Six-digit hex color, e.g. #385b50. */
|
|
12
|
+
accentColor?: string;
|
|
13
|
+
zIndex?: number;
|
|
14
|
+
/** Defaults to true. Stores the visitor credential in this tab's sessionStorage. */
|
|
15
|
+
persistSession?: boolean;
|
|
16
|
+
/** CSP nonce for the widget's isolated stylesheet. */
|
|
17
|
+
nonce?: string;
|
|
18
|
+
/** Minimum 2500ms; defaults to 2500. Only polls while visible. */
|
|
19
|
+
pollInterval?: number;
|
|
20
|
+
/** Receives sanitized errors; no visitor credentials or transcript. */
|
|
21
|
+
onError?: (error: Error) => void;
|
|
22
|
+
}
|
|
23
|
+
export interface CocoaWidget {
|
|
24
|
+
open(): void;
|
|
25
|
+
close(): void;
|
|
26
|
+
toggle(): void;
|
|
27
|
+
/** Forget this tab's session; does not delete its server transcript. */
|
|
28
|
+
reset(): void;
|
|
29
|
+
/** Remove UI, timers, listeners and abort in-flight requests. */
|
|
30
|
+
destroy(): void;
|
|
31
|
+
}
|
|
32
|
+
/** Call after the document body exists. Importing is safe during SSR. */
|
|
33
|
+
export function createCocoa(options: CocoaOptions): CocoaWidget;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
const instances = new Map();
|
|
2
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
+
|
|
4
|
+
export function createCocoa(options = {}) {
|
|
5
|
+
if (typeof document === 'undefined' || !document.body) {
|
|
6
|
+
throw new Error('Mount Cocoa in the browser after the document body exists.');
|
|
7
|
+
}
|
|
8
|
+
if (!uuid.test(options.chatbotId || '')) throw new Error('A valid chatbotId is required.');
|
|
9
|
+
const url = new URL(options.apiUrl || 'https://api.sub-zero.dev');
|
|
10
|
+
if (url.username || url.password || url.search || url.hash ||
|
|
11
|
+
(url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
|
|
12
|
+
throw new Error('apiUrl must use HTTPS (HTTP is allowed only on loopback).');
|
|
13
|
+
}
|
|
14
|
+
const api = url.href.replace(/\/+$/, '');
|
|
15
|
+
const key = `subzero-chat:${api}:${options.chatbotId}`;
|
|
16
|
+
if (instances.has(key)) return instances.get(key);
|
|
17
|
+
const accent = options.accentColor || '#385b50';
|
|
18
|
+
if (!/^#[0-9a-f]{6}$/i.test(accent)) throw new Error('accentColor must be a six-digit hex color.');
|
|
19
|
+
const zIndex = options.zIndex ?? 2147483000;
|
|
20
|
+
if (!Number.isInteger(zIndex) || zIndex < 0 || zIndex > 2147483647) throw new Error('Invalid zIndex.');
|
|
21
|
+
const pollInterval = options.pollInterval ?? 2500;
|
|
22
|
+
if (!Number.isFinite(pollInterval) || pollInterval < 2500) throw new Error('pollInterval must be at least 2500ms.');
|
|
23
|
+
const host = document.createElement('div');
|
|
24
|
+
host.dataset.cocoaWidget = '';
|
|
25
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
26
|
+
const style = document.createElement('style');
|
|
27
|
+
if (options.nonce) style.setAttribute('nonce', options.nonce);
|
|
28
|
+
style.textContent = `
|
|
29
|
+
:host{all:initial;position:fixed;right:max(16px,env(safe-area-inset-right));bottom:max(16px,env(safe-area-inset-bottom));z-index:${zIndex};font:14px/1.5 system-ui,sans-serif;color:#f2f5f8;color-scheme:dark;text-align:left}
|
|
30
|
+
*,*::before,*::after{box-sizing:border-box} [hidden]{display:none!important}
|
|
31
|
+
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}
|
|
32
|
+
.launcher{display:block;margin-left:auto;background:${accent};border-color:#ffffff55;box-shadow:0 6px 24px #0004;font-weight:600;min-height:48px}
|
|
33
|
+
.panel{display:flex;flex-direction:column;width:370px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100dvh - 100px);background:#141c22;border:1px solid #44515b;border-radius:18px;box-shadow:0 12px 50px #0005;overflow:hidden;margin-bottom:12px}
|
|
34
|
+
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}
|
|
35
|
+
.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}
|
|
36
|
+
.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}
|
|
37
|
+
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}
|
|
38
|
+
@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)}}}
|
|
39
|
+
@media(max-height:540px){.panel{height:calc(100dvh - 96px)}.name{display:none}header{padding:4px 12px}form{padding:6px 12px}textarea{height:42px}}
|
|
40
|
+
`;
|
|
41
|
+
root.append(style);
|
|
42
|
+
// Use DOM APIs throughout, including on sites enforcing Trusted Types.
|
|
43
|
+
const element = (tag, attributes = {}, text) => {
|
|
44
|
+
const node = document.createElement(tag);
|
|
45
|
+
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
|
|
46
|
+
if (text !== undefined) node.textContent = text;
|
|
47
|
+
return node;
|
|
48
|
+
};
|
|
49
|
+
const panel = element('section', { class: 'panel', id: 'cocoa-panel', role: 'dialog', 'aria-label': options.title || 'Cocoa support', hidden: '' });
|
|
50
|
+
const header = element('header');
|
|
51
|
+
const heading = element('div');
|
|
52
|
+
heading.append(element('strong'), element('small', {}, 'Powered by SubZero'));
|
|
53
|
+
header.append(heading, element('button', { type: 'button', 'aria-label': 'Close support' }, '\u00d7'));
|
|
54
|
+
const composer = element('form');
|
|
55
|
+
const nameLabel = element('label', { class: 'name' }, 'Your name (optional)');
|
|
56
|
+
nameLabel.append(element('input', { maxlength: '120', autocomplete: 'given-name' }));
|
|
57
|
+
const messageLabel = element('label', {}, 'Your message');
|
|
58
|
+
messageLabel.append(element('textarea', { maxlength: '12000', required: '' }));
|
|
59
|
+
const actions = element('div', { class: 'actions' });
|
|
60
|
+
actions.append(element('button', { class: 'new', type: 'button', hidden: '' }, 'New conversation'), element('button', { class: 'send', type: 'submit' }, 'Send'));
|
|
61
|
+
composer.append(nameLabel, messageLabel, actions);
|
|
62
|
+
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);
|
|
63
|
+
panel.querySelector('strong').textContent = options.title || 'Cocoa support';
|
|
64
|
+
const launcher = document.createElement('button');
|
|
65
|
+
launcher.type = 'button';
|
|
66
|
+
launcher.className = 'launcher';
|
|
67
|
+
launcher.textContent = options.launcherLabel || 'Chat with Cocoa';
|
|
68
|
+
launcher.setAttribute('aria-expanded', 'false');
|
|
69
|
+
launcher.setAttribute('aria-controls', panel.id);
|
|
70
|
+
launcher.setAttribute('aria-haspopup', 'dialog');
|
|
71
|
+
root.append(panel, launcher);
|
|
72
|
+
document.body.append(host);
|
|
73
|
+
const messages = panel.querySelector('.messages');
|
|
74
|
+
const status = panel.querySelector('.status');
|
|
75
|
+
const form = panel.querySelector('form');
|
|
76
|
+
const input = panel.querySelector('textarea');
|
|
77
|
+
const name = panel.querySelector('input');
|
|
78
|
+
const send = panel.querySelector('.send');
|
|
79
|
+
const fresh = panel.querySelector('.new');
|
|
80
|
+
name.value = String(options.visitorLabel || '').slice(0, 120);
|
|
81
|
+
let session = null, pending = null, timer, destroyed = false, sending = false, refreshing = false;
|
|
82
|
+
let generation = 0, terminal = false, paused = false, version = -1, failures = 0;
|
|
83
|
+
const rendered = new Set();
|
|
84
|
+
const requests = new Set();
|
|
85
|
+
const persist = () => {
|
|
86
|
+
if (options.persistSession === false) return;
|
|
87
|
+
try {
|
|
88
|
+
if (session) sessionStorage.setItem(key, JSON.stringify({ ...session, pending }));
|
|
89
|
+
else sessionStorage.removeItem(key);
|
|
90
|
+
} catch { /* Storage may be unavailable; an in-memory session still works. */ }
|
|
91
|
+
};
|
|
92
|
+
if (options.persistSession !== false) {
|
|
93
|
+
try {
|
|
94
|
+
const stored = JSON.parse(sessionStorage.getItem(key) || 'null');
|
|
95
|
+
if (uuid.test(stored?.id || '') && /^[a-f0-9]{64}$/.test(stored?.token || '')) {
|
|
96
|
+
session = { id: stored.id, token: stored.token };
|
|
97
|
+
if (typeof stored.pending?.body === 'string' && stored.pending.body.length <= 12000 && uuid.test(stored.pending.idempotencyKey)) {
|
|
98
|
+
pending = stored.pending;
|
|
99
|
+
input.value = pending.body;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch { /* Ignore malformed storage. */ }
|
|
103
|
+
}
|
|
104
|
+
const welcome = () => {
|
|
105
|
+
const p = document.createElement('p');
|
|
106
|
+
p.className = 'welcome';
|
|
107
|
+
p.textContent = options.welcomeMessage || 'Hi! Tell us what you need help with. Cocoa and your support team can answer here.';
|
|
108
|
+
messages.replaceChildren(p);
|
|
109
|
+
};
|
|
110
|
+
welcome();
|
|
111
|
+
const controls = () => {
|
|
112
|
+
const focusWasInComposer = root.activeElement === input || root.activeElement === send;
|
|
113
|
+
input.disabled = sending || terminal || paused;
|
|
114
|
+
send.disabled = sending || terminal || paused;
|
|
115
|
+
name.disabled = sending;
|
|
116
|
+
fresh.hidden = !terminal;
|
|
117
|
+
panel.querySelector('.name').hidden = !!session;
|
|
118
|
+
send.textContent = sending ? 'Sending...' : 'Send';
|
|
119
|
+
if (focusWasInComposer && !sending && input.disabled && !panel.hidden) {
|
|
120
|
+
(terminal ? fresh : panel.querySelector('header button')).focus();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
controls();
|
|
124
|
+
const report = (error) => {
|
|
125
|
+
status.classList.add('error');
|
|
126
|
+
status.textContent = error.message;
|
|
127
|
+
try { options.onError?.(error); } catch { /* Host callback must not break chat. */ }
|
|
128
|
+
};
|
|
129
|
+
async function call(path, body, credential) {
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
requests.add(controller);
|
|
132
|
+
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetch(api + path, {
|
|
135
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
136
|
+
credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer',
|
|
137
|
+
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
138
|
+
...(credential ? { Authorization: `Bearer ${credential}` } : {}) },
|
|
139
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
140
|
+
signal: controller.signal,
|
|
141
|
+
});
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const error = new Error(response.status === 401 ? 'This conversation has expired. Start a new conversation.' :
|
|
144
|
+
response.status === 429 ? 'Too many requests. Please wait a moment before trying again.' :
|
|
145
|
+
response.status === 404 ? 'Chat is unavailable. Please contact your support team.' :
|
|
146
|
+
response.status === 409 ? 'This conversation is closed or paused.' :
|
|
147
|
+
'Unable to connect. Your unsent message is kept here; please try again.');
|
|
148
|
+
error.status = response.status;
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
return await response.json();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error.status) throw error;
|
|
154
|
+
throw new Error('Unable to connect. Your unsent message is kept here; please try again.');
|
|
155
|
+
} finally { clearTimeout(timeout); requests.delete(controller); }
|
|
156
|
+
}
|
|
157
|
+
function expire(error) {
|
|
158
|
+
if (error.status === 401 || error.status === 404) {
|
|
159
|
+
terminal = true;
|
|
160
|
+
session = null;
|
|
161
|
+
pending = null;
|
|
162
|
+
persist();
|
|
163
|
+
controls();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function paint(data) {
|
|
167
|
+
if (!Array.isArray(data.messages)) throw new Error('Chat returned an invalid response. Please try again.');
|
|
168
|
+
if (data.version !== version) {
|
|
169
|
+
const atBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 60;
|
|
170
|
+
if (data.messages.length) messages.querySelector('.welcome')?.remove();
|
|
171
|
+
for (const m of data.messages) {
|
|
172
|
+
if (rendered.has(m.id) || !['customer', 'agent', 'human'].includes(m.actor)) continue;
|
|
173
|
+
const item = document.createElement('div');
|
|
174
|
+
item.className = `message ${m.actor}`;
|
|
175
|
+
const label = document.createElement('small');
|
|
176
|
+
label.textContent = m.actor === 'customer' ? 'You' : m.actor === 'agent' ? (options.title || 'Cocoa') : 'Support team';
|
|
177
|
+
const text = document.createElement('span');
|
|
178
|
+
text.textContent = m.body;
|
|
179
|
+
item.append(label, text);
|
|
180
|
+
messages.append(item);
|
|
181
|
+
rendered.add(m.id);
|
|
182
|
+
}
|
|
183
|
+
if (atBottom || version < 0) messages.scrollTop = messages.scrollHeight;
|
|
184
|
+
version = data.version;
|
|
185
|
+
}
|
|
186
|
+
terminal = data.state === 'closed';
|
|
187
|
+
paused = !data.enabled;
|
|
188
|
+
status.classList.remove('error');
|
|
189
|
+
status.textContent = terminal ? 'This conversation is closed.' : paused ? 'This chat is paused.' :
|
|
190
|
+
data.state === 'human' ? 'A person is handling your conversation.' :
|
|
191
|
+
data.agentOnline ? 'Connected to support.' : 'The agent is offline. Your messages are saved for the team.';
|
|
192
|
+
controls();
|
|
193
|
+
}
|
|
194
|
+
function schedule() {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
if (!destroyed && !panel.hidden && !document.hidden && session && !terminal) {
|
|
197
|
+
timer = setTimeout(refresh, Math.min(pollInterval * 2 ** failures, 30000));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function refresh() {
|
|
201
|
+
if (destroyed || refreshing || sending || panel.hidden || document.hidden || !session || terminal) return;
|
|
202
|
+
refreshing = true;
|
|
203
|
+
const current = generation;
|
|
204
|
+
try {
|
|
205
|
+
const data = await call(`/chat/sessions/${session.id}`, undefined, session.token);
|
|
206
|
+
if (destroyed || current !== generation) return;
|
|
207
|
+
paint(data);
|
|
208
|
+
failures = 0;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (!destroyed && current === generation) { failures = Math.min(failures + 1, 4); expire(error); report(error); }
|
|
211
|
+
} finally {
|
|
212
|
+
if (current === generation) { refreshing = false; schedule(); }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function close() {
|
|
216
|
+
if (destroyed) return;
|
|
217
|
+
panel.hidden = true;
|
|
218
|
+
launcher.setAttribute('aria-expanded', 'false');
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
launcher.focus();
|
|
221
|
+
}
|
|
222
|
+
function open() {
|
|
223
|
+
if (destroyed) return;
|
|
224
|
+
panel.hidden = false;
|
|
225
|
+
launcher.setAttribute('aria-expanded', 'true');
|
|
226
|
+
(terminal ? fresh : input).focus();
|
|
227
|
+
void refresh();
|
|
228
|
+
}
|
|
229
|
+
function reset() {
|
|
230
|
+
if (destroyed) return;
|
|
231
|
+
generation++;
|
|
232
|
+
sending = refreshing = false;
|
|
233
|
+
for (const request of requests) request.abort();
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
session = pending = null;
|
|
236
|
+
terminal = paused = false;
|
|
237
|
+
version = -1;
|
|
238
|
+
failures = 0;
|
|
239
|
+
rendered.clear();
|
|
240
|
+
persist();
|
|
241
|
+
welcome();
|
|
242
|
+
input.value = '';
|
|
243
|
+
status.textContent = '';
|
|
244
|
+
status.classList.remove('error');
|
|
245
|
+
controls();
|
|
246
|
+
if (!panel.hidden) input.focus();
|
|
247
|
+
}
|
|
248
|
+
const visibility = () => {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
if (!document.hidden) void refresh();
|
|
251
|
+
};
|
|
252
|
+
document.addEventListener('visibilitychange', visibility);
|
|
253
|
+
const widget = { open, close, reset, toggle: () => panel.hidden ? open() : close(), destroy() {
|
|
254
|
+
if (destroyed) return;
|
|
255
|
+
destroyed = true;
|
|
256
|
+
generation++;
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
for (const request of requests) request.abort();
|
|
259
|
+
document.removeEventListener('visibilitychange', visibility);
|
|
260
|
+
host.remove();
|
|
261
|
+
instances.delete(key);
|
|
262
|
+
} };
|
|
263
|
+
launcher.onclick = widget.toggle;
|
|
264
|
+
panel.querySelector('header button').onclick = close;
|
|
265
|
+
fresh.onclick = reset;
|
|
266
|
+
root.addEventListener('keydown', (event) => {
|
|
267
|
+
if (event.key === 'Escape' && !panel.hidden) { event.preventDefault(); close(); }
|
|
268
|
+
});
|
|
269
|
+
form.onsubmit = async (event) => {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
const body = input.value.trim();
|
|
272
|
+
if (!body || sending || terminal || paused || destroyed) return;
|
|
273
|
+
sending = true;
|
|
274
|
+
controls();
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
const current = generation;
|
|
277
|
+
try {
|
|
278
|
+
if (!session) {
|
|
279
|
+
const result = await call(`/chat/${options.chatbotId}/sessions`, { label: name.value.trim() || 'Visitor' });
|
|
280
|
+
if (destroyed || current !== generation) return;
|
|
281
|
+
if (!uuid.test(result.id || '') || !/^[a-f0-9]{64}$/.test(result.token || '')) throw new Error('Chat returned an invalid session. Please try again.');
|
|
282
|
+
session = { id: result.id, token: result.token };
|
|
283
|
+
persist();
|
|
284
|
+
}
|
|
285
|
+
if (!pending || pending.body !== body) pending = { body, idempotencyKey: crypto.randomUUID() };
|
|
286
|
+
persist();
|
|
287
|
+
await call(`/chat/sessions/${session.id}/messages`, pending, session.token);
|
|
288
|
+
if (destroyed || current !== generation) return;
|
|
289
|
+
pending = null;
|
|
290
|
+
persist();
|
|
291
|
+
input.value = '';
|
|
292
|
+
failures = 0;
|
|
293
|
+
status.classList.remove('error');
|
|
294
|
+
status.textContent = 'Message sent.';
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (!destroyed && current === generation) { expire(error); report(error); }
|
|
297
|
+
} finally {
|
|
298
|
+
if (!destroyed && current === generation) {
|
|
299
|
+
sending = false;
|
|
300
|
+
controls();
|
|
301
|
+
// Keep send failures visible until the visitor retries; polling resumes later.
|
|
302
|
+
if (!pending && !terminal) await refresh();
|
|
303
|
+
else schedule();
|
|
304
|
+
if (!panel.hidden && !input.disabled) input.focus();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
instances.set(key, widget);
|
|
309
|
+
return widget;
|
|
310
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sub-zero/cocoa",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drop-in Cocoa support chat for any website, powered by SubZero.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./widget": "./dist/cocoa.js",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"unpkg": "dist/cocoa.js",
|
|
24
|
+
"jsdelivr": "dist/cocoa.js",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": [
|
|
30
|
+
"./dist/cocoa.js"
|
|
31
|
+
],
|
|
32
|
+
"license": "UNLICENSED",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"registry": "https://registry.npmjs.org/"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/thetechjd/subzero-frontend.git",
|
|
40
|
+
"directory": "packages/cocoa"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "node build.mjs",
|
|
47
|
+
"test": "npm run build && node --test test/package.test.mjs && node test/browser.cjs",
|
|
48
|
+
"prepack": "npm run build"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"cocoa",
|
|
52
|
+
"subzero",
|
|
53
|
+
"chatbot",
|
|
54
|
+
"support",
|
|
55
|
+
"widget"
|
|
56
|
+
]
|
|
57
|
+
}
|