@zoomrx/zena-chatbot-client 1.0.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 ADDED
@@ -0,0 +1,211 @@
1
+ # Zena Chatbot
2
+
3
+ `@zoomrx/zena-chatbot-client` is a standalone web component (`<zena-chatbot>`) built with Svelte 5 and bundled via Vite into a single IIFE file. It is embedded inside host applications as a script tag — no framework dependency required on the host side.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npm install
9
+ npm run dev # dev server at http://localhost:5173
10
+ ```
11
+
12
+ > Requires Node.js 24 or newer. Use `nvm use --lts` if you manage multiple runtimes.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @zoomrx/zena-chatbot-client
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Tech stack
23
+
24
+ - **Framework**: Svelte 5 (runes: `$state`, `$derived`, `$effect`, `$props`)
25
+ - **Build tool**: Vite 8 (outputs ES module + IIFE)
26
+ - **Styles**: Tailwind CSS 4
27
+ - **HTTP**: ky with a memoized instance factory
28
+ - **Testing**: Vitest + @testing-library/svelte + MSW
29
+ - **Error tracking**: Sentry (`@sentry/browser`)
30
+ - **Analytics**: PostHog
31
+ - **E2E**: Playwright
32
+
33
+ ---
34
+
35
+ ## Scripts
36
+
37
+ | Command | Purpose |
38
+ |---|---|
39
+ | `npm run dev` | Dev server at http://localhost:5173 with MSW + PostHog stub |
40
+ | `npm run build` | Production IIFE + ES module in `dist/` |
41
+ | `npm run preview` | Serve the production build locally |
42
+ | `npm test -- --run` | Run all unit tests once |
43
+ | `npm run test:coverage` | Coverage report |
44
+ | `node e2e-test.cjs` | Playwright E2E test against localhost:5173 |
45
+
46
+ ---
47
+
48
+ ## Web Component API
49
+
50
+ ### Usage
51
+
52
+ The recommended approach is to pass **all configuration via `get-config`** — one event, one place.
53
+
54
+ ```html
55
+ <script src="path/to/zena-chatbot-client.iife.js"></script>
56
+
57
+ <zena-chatbot></zena-chatbot>
58
+
59
+ <script>
60
+ const chatbot = document.querySelector('zena-chatbot');
61
+
62
+ // All config — URLs, user ID, tokens, analytics keys — resolved in one place.
63
+ chatbot.on('get-config', ({ resolve }) => {
64
+ resolve({
65
+ authToken: 'Bearer eyJ...', // required
66
+ zenaServerUrl: 'https://your-zena-api.com',
67
+ panelistServerUrl: 'https://your-panelist-server.com',
68
+ userId: '12345',
69
+ availableSurveyIds: '[1, 2, 3]',
70
+ nativeApp: false,
71
+ fabStyle: { bottom: '20px', right: '20px' },
72
+ fabShape: 'circle',
73
+ autoSuggestions: ["I'm stuck in the survey"],
74
+ posthogKey: 'phc_xxx',
75
+ posthogHost: 'https://app.posthog.com',
76
+ sentryDsn: 'https://xxx@sentry.io/123',
77
+ sentryRelease: 'zena-chatbot-client@1.0.0', // optional
78
+ });
79
+ });
80
+ </script>
81
+ ```
82
+
83
+ HTML attributes are also supported as a fallback (e.g. for static HTML pages), but `get-config` values take precedence when both are provided.
84
+
85
+ ### HTML Attributes (fallback)
86
+
87
+ All of these can alternatively be passed via `get-config` (see Events below). When both are provided, `get-config` wins.
88
+
89
+ | Attribute | Type | Required | Default | Description |
90
+ |---|---|---|---|---|
91
+ | `zena-server-url` | `string` | Yes* | — | Base URL for the Zena API |
92
+ | `user-id` | `string` | No | `''` | User identifier passed to analytics |
93
+ | `available-survey-ids` | `string` (JSON array) | No | `'[]'` | Array of IDs (or objects with `survey_id`) currently on the user's dashboard |
94
+ | `panelist-server-url` | `string` | No | `''` | Base URL for the panelist server (survey fetching) |
95
+ | `native-app` | `boolean` (presence) | No | `false` | Set to enable native app mode |
96
+ | `fab-style` | `string` (JSON object) | No | `{}` | CSS properties for FAB position/size (e.g. `{"bottom":"20px","right":"20px"}`) |
97
+ | `fab-shape` | `string` | No | `'circle'` | `'circle'` or `'rectangle'` |
98
+ | `auto-suggestions` | `string` (JSON array) | No | `[]` | Priority suggestions prepended to defaults |
99
+
100
+ *Not required if `zenaServerUrl` is passed via `get-config`.
101
+
102
+ ### Methods
103
+
104
+ | Method | Returns | Description |
105
+ |---|---|---|
106
+ | `close()` | `void` | Programmatically close the chat panel |
107
+ | `generateConversationSummary()` | `string` | Returns last user + bot message as plain text (useful for support ticket context) |
108
+ | `setFabStyle(style)` | `void` | Update FAB position/style without remounting — preserves chat state |
109
+ | `on(event, handler)` | `void` | Subscribe to a component event |
110
+ | `off(event, handler)` | `void` | Unsubscribe from a component event |
111
+
112
+ ### Events
113
+
114
+ | Event | Payload | Description |
115
+ |---|---|---|
116
+ | `get-config` | `{ resolve }` | Fired once on init. Call `resolve(config)` to provide sensitive credentials. See below. |
117
+ | `before-send` | `{ message, resolve }` | Fired before each message is sent. Call `resolve(data)` with any extra data to merge into the request. |
118
+ | `stuck-survey-selected` | `{ survey }` | Fire-and-forget. Fired when the user selects a survey from a stuck-survey dropdown, before the support ticket is created. |
119
+
120
+ #### `get-config`
121
+
122
+ Fired once when the component connects. The host must call `resolve` with a
123
+ `ZenaChatbotConfig` object. If the host does not handle the event within 2 seconds
124
+ the component mounts with an empty auth token (API calls will fail).
125
+
126
+ ```js
127
+ chatbot.on('get-config', ({ resolve }) => {
128
+ resolve({
129
+ // Credentials (required / recommended)
130
+ authToken: 'Bearer eyJ...', // required
131
+ sentryDsn: 'https://...', // optional — omit to disable Sentry
132
+ sentryRelease: '1.0.0', // optional — defaults to package version
133
+ posthogKey: 'phc_xxx', // optional — omit to disable analytics
134
+ posthogHost: 'https://app.posthog.com', // optional
135
+
136
+ // Component config (can also be set via HTML attributes as fallback)
137
+ zenaServerUrl: 'https://your-zena-api.com', // required
138
+ panelistServerUrl: 'https://your-panelist.com', // optional
139
+ userId: '12345', // optional
140
+ availableSurveyIds: '[1, 2, 3]', // optional
141
+ nativeApp: false, // optional
142
+ fabStyle: { bottom: '20px', right: '20px' }, // optional
143
+ fabShape: 'circle', // optional — 'circle' | 'rectangle'
144
+ autoSuggestions: ["I'm stuck in the survey"], // optional
145
+ defaultOpen: false, // optional — open panel on mount
146
+ initialMessage: { // optional — hidden trigger sent to server;
147
+ type: 'assistant', // only the assistant response is shown.
148
+ text: 'Hello! How can I help?', // Skipped on page reload automatically.
149
+ },
150
+ });
151
+ });
152
+ ```
153
+
154
+ #### `before-send`
155
+
156
+ ```js
157
+ chatbot.on('before-send', ({ message, resolve }) => {
158
+ resolve({ userId: 42, sessionToken: 'abc' });
159
+ });
160
+ ```
161
+
162
+ #### `stuck-survey-selected`
163
+
164
+ Fired when the user picks a survey from a `stuck_survey_dropdown` response. Fires **before** the support-ticket API call — the host receives it even if ticket creation subsequently fails.
165
+
166
+ ```js
167
+ chatbot.on('stuck-survey-selected', ({ survey }) => {
168
+ // survey — the selected survey object { name, survey_id, wave_id, users_wave_id, ... }
169
+ console.log('User is stuck in survey:', survey.name);
170
+ });
171
+ ```
172
+
173
+ ---
174
+
175
+ ## Sentry Error Tracking
176
+
177
+ The package ships with Sentry support but **does not auto-initialize it**. Provide
178
+ `sentryDsn` via the `get-config` event — the component initialises Sentry after
179
+ credentials are resolved:
180
+
181
+ ```js
182
+ chatbot.on('get-config', ({ resolve }) => {
183
+ resolve({
184
+ authToken: '...',
185
+ sentryDsn: 'https://YOUR_DSN@oXXXXXX.ingest.sentry.io/XXXXXX',
186
+ sentryRelease: 'zena-chatbot-client@1.0.0', // optional
187
+ });
188
+ });
189
+ ```
190
+
191
+ - `sentryRelease` is optional. When omitted it defaults to `zena-chatbot-client@<package version>` and must match the release name used when uploading sourcemaps in CI (`sentry-cli sourcemaps upload`).
192
+ - Omit `sentryDsn` from the resolved config in local / UAT environments and Sentry will never initialise.
193
+ - `tracesSampleRate` is always `0` — errors only, no performance tracing overhead.
194
+
195
+ ### Capturing errors manually
196
+
197
+ `captureError` is the only Sentry helper exported from the package:
198
+
199
+ ```js
200
+ import { captureError } from '@zoomrx/zena-chatbot-client';
201
+
202
+ try {
203
+ // ...
204
+ } catch (err) {
205
+ captureError(err);
206
+ }
207
+ ```
208
+
209
+ ---
210
+
211
+ For contribution guidelines, staging workflow, and publishing instructions see [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,26 @@
1
+ <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <rect width="32" height="32" fill="black"/>
3
+ <path d="M15.3825 15.9305L7.84295 11.3761C7.59382 11.2254 7.36435 11.0374 7.17859 10.8079C5.90014 9.22135 6.94694 6.62292 9.13889 6.62292H21.6262C23.8181 6.62292 24.8671 9.22135 23.5887 10.8079C23.4051 11.0374 23.1735 11.2254 22.9243 11.3761L15.3847 15.9327L15.3825 15.9305Z" fill="url(#paint0_linear_281_133)"/>
4
+ <path d="M16.4403 15.8912L23.9799 20.4477C24.229 20.5985 24.4585 20.7864 24.6442 21.0159C25.9227 22.6047 24.8759 25.2009 22.6839 25.2009H10.1966C8.00468 25.2009 6.95788 22.6025 8.23633 21.0159C8.41991 20.7864 8.65156 20.5985 8.90069 20.4477L16.4403 15.8912Z" fill="url(#paint1_linear_281_133)"/>
5
+ <path d="M19.7601 7.55691L7.55689 19.7602C6.31256 21.0045 6.31256 23.022 7.55689 24.2663C8.80121 25.5106 10.8187 25.5106 12.063 24.2663L24.2663 12.063C25.5106 10.8187 25.5106 8.80124 24.2663 7.55692C23.0219 6.31259 21.0045 6.31259 19.7601 7.55691Z" fill="white"/>
6
+ <defs>
7
+ <linearGradient id="paint0_linear_281_133" x1="7.96315" y1="4.67793" x2="22.225" y2="13.3277" gradientUnits="userSpaceOnUse">
8
+ <stop stop-color="white"/>
9
+ <stop offset="0.17" stop-color="white" stop-opacity="0.78"/>
10
+ <stop offset="0.4" stop-color="white" stop-opacity="0.5"/>
11
+ <stop offset="0.6" stop-color="white" stop-opacity="0.29"/>
12
+ <stop offset="0.78" stop-color="white" stop-opacity="0.13"/>
13
+ <stop offset="0.92" stop-color="white" stop-opacity="0.04"/>
14
+ <stop offset="1" stop-color="white" stop-opacity="0"/>
15
+ </linearGradient>
16
+ <linearGradient id="paint1_linear_281_133" x1="23.8597" y1="27.1459" x2="9.59783" y2="18.494" gradientUnits="userSpaceOnUse">
17
+ <stop stop-color="white"/>
18
+ <stop offset="0.17" stop-color="white" stop-opacity="0.78"/>
19
+ <stop offset="0.4" stop-color="white" stop-opacity="0.5"/>
20
+ <stop offset="0.6" stop-color="white" stop-opacity="0.29"/>
21
+ <stop offset="0.78" stop-color="white" stop-opacity="0.13"/>
22
+ <stop offset="0.92" stop-color="white" stop-opacity="0.04"/>
23
+ <stop offset="1" stop-color="white" stop-opacity="0"/>
24
+ </linearGradient>
25
+ </defs>
26
+ </svg>
@@ -0,0 +1,349 @@
1
+ /* eslint-disable */
2
+ /* tslint:disable */
3
+
4
+ /**
5
+ * Mock Service Worker.
6
+ * @see https://github.com/mswjs/msw
7
+ * - Please do NOT modify this file.
8
+ */
9
+
10
+ const PACKAGE_VERSION = '2.14.3'
11
+ const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
12
+ const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
13
+ const activeClientIds = new Set()
14
+
15
+ addEventListener('install', function () {
16
+ self.skipWaiting()
17
+ })
18
+
19
+ addEventListener('activate', function (event) {
20
+ event.waitUntil(self.clients.claim())
21
+ })
22
+
23
+ addEventListener('message', async function (event) {
24
+ const clientId = Reflect.get(event.source || {}, 'id')
25
+
26
+ if (!clientId || !self.clients) {
27
+ return
28
+ }
29
+
30
+ const client = await self.clients.get(clientId)
31
+
32
+ if (!client) {
33
+ return
34
+ }
35
+
36
+ const allClients = await self.clients.matchAll({
37
+ type: 'window',
38
+ })
39
+
40
+ switch (event.data) {
41
+ case 'KEEPALIVE_REQUEST': {
42
+ sendToClient(client, {
43
+ type: 'KEEPALIVE_RESPONSE',
44
+ })
45
+ break
46
+ }
47
+
48
+ case 'INTEGRITY_CHECK_REQUEST': {
49
+ sendToClient(client, {
50
+ type: 'INTEGRITY_CHECK_RESPONSE',
51
+ payload: {
52
+ packageVersion: PACKAGE_VERSION,
53
+ checksum: INTEGRITY_CHECKSUM,
54
+ },
55
+ })
56
+ break
57
+ }
58
+
59
+ case 'MOCK_ACTIVATE': {
60
+ activeClientIds.add(clientId)
61
+
62
+ sendToClient(client, {
63
+ type: 'MOCKING_ENABLED',
64
+ payload: {
65
+ client: {
66
+ id: client.id,
67
+ frameType: client.frameType,
68
+ },
69
+ },
70
+ })
71
+ break
72
+ }
73
+
74
+ case 'CLIENT_CLOSED': {
75
+ activeClientIds.delete(clientId)
76
+
77
+ const remainingClients = allClients.filter((client) => {
78
+ return client.id !== clientId
79
+ })
80
+
81
+ // Unregister itself when there are no more clients
82
+ if (remainingClients.length === 0) {
83
+ self.registration.unregister()
84
+ }
85
+
86
+ break
87
+ }
88
+ }
89
+ })
90
+
91
+ addEventListener('fetch', function (event) {
92
+ const requestInterceptedAt = Date.now()
93
+
94
+ // Bypass navigation requests.
95
+ if (event.request.mode === 'navigate') {
96
+ return
97
+ }
98
+
99
+ // Opening the DevTools triggers the "only-if-cached" request
100
+ // that cannot be handled by the worker. Bypass such requests.
101
+ if (
102
+ event.request.cache === 'only-if-cached' &&
103
+ event.request.mode !== 'same-origin'
104
+ ) {
105
+ return
106
+ }
107
+
108
+ // Bypass all requests when there are no active clients.
109
+ // Prevents the self-unregistered worked from handling requests
110
+ // after it's been terminated (still remains active until the next reload).
111
+ if (activeClientIds.size === 0) {
112
+ return
113
+ }
114
+
115
+ const requestId = crypto.randomUUID()
116
+ event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
117
+ })
118
+
119
+ /**
120
+ * @param {FetchEvent} event
121
+ * @param {string} requestId
122
+ * @param {number} requestInterceptedAt
123
+ */
124
+ async function handleRequest(event, requestId, requestInterceptedAt) {
125
+ const client = await resolveMainClient(event)
126
+ const requestCloneForEvents = event.request.clone()
127
+ const response = await getResponse(
128
+ event,
129
+ client,
130
+ requestId,
131
+ requestInterceptedAt,
132
+ )
133
+
134
+ // Send back the response clone for the "response:*" life-cycle events.
135
+ // Ensure MSW is active and ready to handle the message, otherwise
136
+ // this message will pend indefinitely.
137
+ if (client && activeClientIds.has(client.id)) {
138
+ const serializedRequest = await serializeRequest(requestCloneForEvents)
139
+
140
+ // Clone the response so both the client and the library could consume it.
141
+ const responseClone = response.clone()
142
+
143
+ sendToClient(
144
+ client,
145
+ {
146
+ type: 'RESPONSE',
147
+ payload: {
148
+ isMockedResponse: IS_MOCKED_RESPONSE in response,
149
+ request: {
150
+ id: requestId,
151
+ ...serializedRequest,
152
+ },
153
+ response: {
154
+ type: responseClone.type,
155
+ status: responseClone.status,
156
+ statusText: responseClone.statusText,
157
+ headers: Object.fromEntries(responseClone.headers.entries()),
158
+ body: responseClone.body,
159
+ },
160
+ },
161
+ },
162
+ responseClone.body ? [serializedRequest.body, responseClone.body] : [],
163
+ )
164
+ }
165
+
166
+ return response
167
+ }
168
+
169
+ /**
170
+ * Resolve the main client for the given event.
171
+ * Client that issues a request doesn't necessarily equal the client
172
+ * that registered the worker. It's with the latter the worker should
173
+ * communicate with during the response resolving phase.
174
+ * @param {FetchEvent} event
175
+ * @returns {Promise<Client | undefined>}
176
+ */
177
+ async function resolveMainClient(event) {
178
+ const client = await self.clients.get(event.clientId)
179
+
180
+ if (activeClientIds.has(event.clientId)) {
181
+ return client
182
+ }
183
+
184
+ if (client?.frameType === 'top-level') {
185
+ return client
186
+ }
187
+
188
+ const allClients = await self.clients.matchAll({
189
+ type: 'window',
190
+ })
191
+
192
+ return allClients
193
+ .filter((client) => {
194
+ // Get only those clients that are currently visible.
195
+ return client.visibilityState === 'visible'
196
+ })
197
+ .find((client) => {
198
+ // Find the client ID that's recorded in the
199
+ // set of clients that have registered the worker.
200
+ return activeClientIds.has(client.id)
201
+ })
202
+ }
203
+
204
+ /**
205
+ * @param {FetchEvent} event
206
+ * @param {Client | undefined} client
207
+ * @param {string} requestId
208
+ * @param {number} requestInterceptedAt
209
+ * @returns {Promise<Response>}
210
+ */
211
+ async function getResponse(event, client, requestId, requestInterceptedAt) {
212
+ // Clone the request because it might've been already used
213
+ // (i.e. its body has been read and sent to the client).
214
+ const requestClone = event.request.clone()
215
+
216
+ function passthrough() {
217
+ // Cast the request headers to a new Headers instance
218
+ // so the headers can be manipulated with.
219
+ const headers = new Headers(requestClone.headers)
220
+
221
+ // Remove the "accept" header value that marked this request as passthrough.
222
+ // This prevents request alteration and also keeps it compliant with the
223
+ // user-defined CORS policies.
224
+ const acceptHeader = headers.get('accept')
225
+ if (acceptHeader) {
226
+ const values = acceptHeader.split(',').map((value) => value.trim())
227
+ const filteredValues = values.filter(
228
+ (value) => value !== 'msw/passthrough',
229
+ )
230
+
231
+ if (filteredValues.length > 0) {
232
+ headers.set('accept', filteredValues.join(', '))
233
+ } else {
234
+ headers.delete('accept')
235
+ }
236
+ }
237
+
238
+ return fetch(requestClone, { headers })
239
+ }
240
+
241
+ // Bypass mocking when the client is not active.
242
+ if (!client) {
243
+ return passthrough()
244
+ }
245
+
246
+ // Bypass initial page load requests (i.e. static assets).
247
+ // The absence of the immediate/parent client in the map of the active clients
248
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
249
+ // and is not ready to handle requests.
250
+ if (!activeClientIds.has(client.id)) {
251
+ return passthrough()
252
+ }
253
+
254
+ // Notify the client that a request has been intercepted.
255
+ const serializedRequest = await serializeRequest(event.request)
256
+ const clientMessage = await sendToClient(
257
+ client,
258
+ {
259
+ type: 'REQUEST',
260
+ payload: {
261
+ id: requestId,
262
+ interceptedAt: requestInterceptedAt,
263
+ ...serializedRequest,
264
+ },
265
+ },
266
+ [serializedRequest.body],
267
+ )
268
+
269
+ switch (clientMessage.type) {
270
+ case 'MOCK_RESPONSE': {
271
+ return respondWithMock(clientMessage.data)
272
+ }
273
+
274
+ case 'PASSTHROUGH': {
275
+ return passthrough()
276
+ }
277
+ }
278
+
279
+ return passthrough()
280
+ }
281
+
282
+ /**
283
+ * @param {Client} client
284
+ * @param {any} message
285
+ * @param {Array<Transferable>} transferrables
286
+ * @returns {Promise<any>}
287
+ */
288
+ function sendToClient(client, message, transferrables = []) {
289
+ return new Promise((resolve, reject) => {
290
+ const channel = new MessageChannel()
291
+
292
+ channel.port1.onmessage = (event) => {
293
+ if (event.data && event.data.error) {
294
+ return reject(event.data.error)
295
+ }
296
+
297
+ resolve(event.data)
298
+ }
299
+
300
+ client.postMessage(message, [
301
+ channel.port2,
302
+ ...transferrables.filter(Boolean),
303
+ ])
304
+ })
305
+ }
306
+
307
+ /**
308
+ * @param {Response} response
309
+ * @returns {Response}
310
+ */
311
+ function respondWithMock(response) {
312
+ // Setting response status code to 0 is a no-op.
313
+ // However, when responding with a "Response.error()", the produced Response
314
+ // instance will have status code set to 0. Since it's not possible to create
315
+ // a Response instance with status code 0, handle that use-case separately.
316
+ if (response.status === 0) {
317
+ return Response.error()
318
+ }
319
+
320
+ const mockedResponse = new Response(response.body, response)
321
+
322
+ Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
323
+ value: true,
324
+ enumerable: true,
325
+ })
326
+
327
+ return mockedResponse
328
+ }
329
+
330
+ /**
331
+ * @param {Request} request
332
+ */
333
+ async function serializeRequest(request) {
334
+ return {
335
+ url: request.url,
336
+ mode: request.mode,
337
+ method: request.method,
338
+ headers: Object.fromEntries(request.headers.entries()),
339
+ cache: request.cache,
340
+ credentials: request.credentials,
341
+ destination: request.destination,
342
+ integrity: request.integrity,
343
+ redirect: request.redirect,
344
+ referrer: request.referrer,
345
+ referrerPolicy: request.referrerPolicy,
346
+ body: await request.arrayBuffer(),
347
+ keepalive: request.keepalive,
348
+ }
349
+ }