@brandfine/client 0.11.0 → 0.14.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/dist/index.js CHANGED
@@ -2,6 +2,356 @@ export { createCache, createKeyedCache } from './chunk-DHQHUIFO.js';
2
2
  export { isLocale, localizePath, pickLocale, resolveNavigation, stripLocalePrefix } from './chunk-U6VJX7PP.js';
3
3
  export { createBrandfineWebhookHandler, parseWebhookPayload, verifyWebhookSecret } from './chunk-QQLAYITF.js';
4
4
 
5
+ // src/live-chat-session.ts
6
+ var SESSION_STORAGE_KEY = "bf-live-chat-session";
7
+ function tokenStorageKey(publishableKey) {
8
+ return `bf-live-chat-token:${publishableKey.slice(-12)}`;
9
+ }
10
+ function getVisitorSessionId() {
11
+ try {
12
+ const existing = localStorage.getItem(SESSION_STORAGE_KEY);
13
+ if (existing) return existing;
14
+ const generated = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "") : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
15
+ localStorage.setItem(SESSION_STORAGE_KEY, generated);
16
+ return generated;
17
+ } catch {
18
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
19
+ }
20
+ }
21
+ function readStoredToken(publishableKey) {
22
+ try {
23
+ return localStorage.getItem(tokenStorageKey(publishableKey)) ?? void 0;
24
+ } catch {
25
+ return void 0;
26
+ }
27
+ }
28
+ function writeStoredToken(publishableKey, token) {
29
+ try {
30
+ if (token === null) localStorage.removeItem(tokenStorageKey(publishableKey));
31
+ else localStorage.setItem(tokenStorageKey(publishableKey), token);
32
+ } catch {
33
+ }
34
+ }
35
+ function resolvePageLocale(explicit) {
36
+ if (explicit) return explicit;
37
+ if (typeof document === "undefined") return void 0;
38
+ const htmlLang = document.documentElement.lang?.trim();
39
+ if (htmlLang) return htmlLang;
40
+ return typeof navigator !== "undefined" ? navigator.language || void 0 : void 0;
41
+ }
42
+ async function createLiveChatSession(wire, opts) {
43
+ if (typeof window === "undefined") {
44
+ throw new Error(
45
+ "liveChat.createSession() is browser-only \u2014 build the transcript UI client-side; fetch the bootstrap with getConfig() on the server."
46
+ );
47
+ }
48
+ const publishableKey = opts.config.enabled ? opts.config.publishableKey : void 0;
49
+ const locale = resolvePageLocale(opts.locale);
50
+ const pollMs = Math.max(1e3, opts.pollIntervalMs ?? 5e3);
51
+ let snapshot = {
52
+ messages: [],
53
+ status: "CONNECTING",
54
+ online: false,
55
+ greeting: null,
56
+ offlineMessage: null,
57
+ sending: false,
58
+ error: null
59
+ };
60
+ const listeners = /* @__PURE__ */ new Set();
61
+ const seen = /* @__PURE__ */ new Set();
62
+ let cursor;
63
+ let lastEventId = 0;
64
+ let token;
65
+ let timer;
66
+ let closed = false;
67
+ let ticking = false;
68
+ const aborter = new AbortController();
69
+ let realtimeUrl = opts.config.realtimeUrl;
70
+ let ws;
71
+ let wsLive = false;
72
+ let wsFailures = 0;
73
+ let wsGivenUp = false;
74
+ let reconnectTimer;
75
+ let pingTimer;
76
+ function emit(patch) {
77
+ snapshot = { ...snapshot, ...patch };
78
+ for (const listener of listeners) listener(snapshot);
79
+ }
80
+ function appendMessages(incoming) {
81
+ const fresh = incoming.filter((m) => !seen.has(m.id));
82
+ if (fresh.length === 0) {
83
+ for (const m of incoming) {
84
+ if (typeof m.eventId === "number" && m.eventId > lastEventId) {
85
+ lastEventId = m.eventId;
86
+ }
87
+ }
88
+ return;
89
+ }
90
+ for (const m of fresh) {
91
+ seen.add(m.id);
92
+ if (typeof m.eventId === "number" && m.eventId > lastEventId) {
93
+ lastEventId = m.eventId;
94
+ }
95
+ }
96
+ const messages = [...snapshot.messages, ...fresh];
97
+ cursor = messages[messages.length - 1].createdAt;
98
+ emit({ messages });
99
+ }
100
+ function markClosed() {
101
+ if (snapshot.status !== "CLOSED") emit({ status: "CLOSED" });
102
+ stopPolling();
103
+ teardownRealtime();
104
+ wsGivenUp = true;
105
+ }
106
+ async function tick() {
107
+ if (ticking || closed || !token) return;
108
+ ticking = true;
109
+ try {
110
+ const result = await wire.history(
111
+ token,
112
+ // The eventId cursor is exact (no equal-millisecond drop
113
+ // window); the timestamp cursor remains for old APIs that
114
+ // never sent eventIds.
115
+ lastEventId > 0 ? { afterEvent: lastEventId } : { after: cursor }
116
+ );
117
+ if (closed) return;
118
+ appendMessages(result.messages);
119
+ if (result.status === "CLOSED") markClosed();
120
+ } catch {
121
+ } finally {
122
+ ticking = false;
123
+ }
124
+ }
125
+ function startPolling() {
126
+ stopPolling();
127
+ timer = setInterval(() => void tick(), pollMs);
128
+ }
129
+ function stopPolling() {
130
+ if (timer !== void 0) clearInterval(timer);
131
+ timer = void 0;
132
+ }
133
+ function teardownRealtime() {
134
+ if (reconnectTimer !== void 0) clearTimeout(reconnectTimer);
135
+ reconnectTimer = void 0;
136
+ if (pingTimer !== void 0) clearInterval(pingTimer);
137
+ pingTimer = void 0;
138
+ wsLive = false;
139
+ if (ws) {
140
+ const socket = ws;
141
+ ws = void 0;
142
+ try {
143
+ socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
144
+ socket.close();
145
+ } catch {
146
+ }
147
+ }
148
+ }
149
+ function scheduleReconnect() {
150
+ if (closed || wsGivenUp || reconnectTimer !== void 0) return;
151
+ if (wsFailures >= 5) {
152
+ wsGivenUp = true;
153
+ return;
154
+ }
155
+ const base = Math.min(3e4, 1e3 * 2 ** wsFailures);
156
+ const delay = base / 2 + Math.random() * (base / 2);
157
+ reconnectTimer = setTimeout(() => {
158
+ reconnectTimer = void 0;
159
+ startRealtime();
160
+ }, delay);
161
+ }
162
+ function handleSocketDown() {
163
+ teardownRealtime();
164
+ if (closed || wsGivenUp || snapshot.status === "CLOSED") return;
165
+ startPolling();
166
+ wsFailures += 1;
167
+ scheduleReconnect();
168
+ }
169
+ function startRealtime() {
170
+ if (closed || wsGivenUp || wsLive || ws !== void 0 || !realtimeUrl || !token || !publishableKey || typeof WebSocket === "undefined" || snapshot.status === "CLOSED") {
171
+ return;
172
+ }
173
+ let socket;
174
+ try {
175
+ socket = new WebSocket(realtimeUrl);
176
+ } catch {
177
+ wsFailures += 1;
178
+ scheduleReconnect();
179
+ return;
180
+ }
181
+ ws = socket;
182
+ socket.onopen = () => {
183
+ try {
184
+ socket.send(
185
+ JSON.stringify({
186
+ type: "auth",
187
+ publishableKey,
188
+ conversationToken: token,
189
+ lastEventId
190
+ })
191
+ );
192
+ } catch {
193
+ handleSocketDown();
194
+ }
195
+ };
196
+ socket.onmessage = (event) => {
197
+ let frame;
198
+ try {
199
+ frame = JSON.parse(String(event.data));
200
+ } catch {
201
+ return;
202
+ }
203
+ switch (frame.type) {
204
+ case "ready": {
205
+ wsLive = true;
206
+ wsFailures = 0;
207
+ stopPolling();
208
+ if (pingTimer === void 0) {
209
+ pingTimer = setInterval(() => {
210
+ try {
211
+ socket.send(JSON.stringify({ type: "ping" }));
212
+ } catch {
213
+ }
214
+ }, 3e4);
215
+ }
216
+ break;
217
+ }
218
+ case "message": {
219
+ const m = frame.message;
220
+ if (m && typeof m.id === "string") {
221
+ appendMessages([
222
+ {
223
+ ...m,
224
+ eventId: typeof frame.eventId === "number" ? frame.eventId : m.eventId
225
+ }
226
+ ]);
227
+ }
228
+ break;
229
+ }
230
+ case "status": {
231
+ if (frame.status === "CLOSED") markClosed();
232
+ break;
233
+ }
234
+ case "error": {
235
+ wsGivenUp = true;
236
+ teardownRealtime();
237
+ if (!closed && snapshot.status !== "CLOSED") startPolling();
238
+ break;
239
+ }
240
+ }
241
+ };
242
+ socket.onclose = () => handleSocketDown();
243
+ socket.onerror = () => {
244
+ };
245
+ }
246
+ async function connect(resumeToken) {
247
+ if (!publishableKey) {
248
+ emit({ status: "CLOSED" });
249
+ return;
250
+ }
251
+ try {
252
+ const cfg = await wire.runtimeConfig(locale);
253
+ if (closed) return;
254
+ if (!cfg.enabled) {
255
+ emit({ status: "CLOSED" });
256
+ return;
257
+ }
258
+ emit({
259
+ greeting: cfg.greeting,
260
+ offlineMessage: cfg.offlineMessage,
261
+ online: cfg.online !== false
262
+ });
263
+ if (cfg.realtimeUrl !== void 0) realtimeUrl = cfg.realtimeUrl;
264
+ } catch (error) {
265
+ if (closed) return;
266
+ emit({ status: "ERROR", error });
267
+ return;
268
+ }
269
+ try {
270
+ const started = await wire.startConversation({
271
+ visitorSessionId: getVisitorSessionId(),
272
+ conversationToken: resumeToken,
273
+ pageUrl: opts.pageUrl ?? (typeof location !== "undefined" ? location.href.slice(0, 2048) : void 0),
274
+ referrer: opts.referrer,
275
+ locale,
276
+ visitor: opts.visitor
277
+ });
278
+ if (closed) return;
279
+ if (!started.enabled) {
280
+ emit({ status: "CLOSED" });
281
+ return;
282
+ }
283
+ token = started.conversationToken;
284
+ writeStoredToken(publishableKey, token);
285
+ if (started.online !== void 0) emit({ online: started.online });
286
+ emit({ status: "OPEN" });
287
+ await tick();
288
+ if (!closed) {
289
+ startPolling();
290
+ startRealtime();
291
+ }
292
+ } catch (error) {
293
+ if (closed) return;
294
+ emit({ status: "ERROR", error });
295
+ }
296
+ }
297
+ const session = {
298
+ get messages() {
299
+ return snapshot.messages;
300
+ },
301
+ get state() {
302
+ return snapshot.status;
303
+ },
304
+ getSnapshot: () => snapshot,
305
+ subscribe(listener) {
306
+ listeners.add(listener);
307
+ return () => listeners.delete(listener);
308
+ },
309
+ async send(body) {
310
+ const trimmed = body.trim();
311
+ if (!trimmed) throw new Error("send(): message body is empty");
312
+ if (!token) throw new Error("send(): session is not connected");
313
+ if (snapshot.status === "CLOSED") {
314
+ throw new Error("send(): conversation is closed \u2014 call reset()");
315
+ }
316
+ emit({ sending: true });
317
+ try {
318
+ const clientId = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
319
+ const message = await wire.sendMessage(token, {
320
+ body: trimmed,
321
+ clientId
322
+ });
323
+ appendMessages([message]);
324
+ return message;
325
+ } finally {
326
+ emit({ sending: false });
327
+ }
328
+ },
329
+ close() {
330
+ if (closed) return;
331
+ closed = true;
332
+ stopPolling();
333
+ teardownRealtime();
334
+ aborter.abort();
335
+ listeners.clear();
336
+ },
337
+ async reset() {
338
+ teardownRealtime();
339
+ wsFailures = 0;
340
+ wsGivenUp = false;
341
+ if (publishableKey) writeStoredToken(publishableKey, null);
342
+ token = void 0;
343
+ cursor = void 0;
344
+ lastEventId = 0;
345
+ seen.clear();
346
+ stopPolling();
347
+ emit({ messages: [], status: "CONNECTING", error: null });
348
+ await connect(void 0);
349
+ }
350
+ };
351
+ await connect(publishableKey ? readStoredToken(publishableKey) : void 0);
352
+ return session;
353
+ }
354
+
5
355
  // src/client.ts
6
356
  var BrandfineApiError = class extends Error {
7
357
  name = "BrandfineApiError";
@@ -21,6 +371,23 @@ var BrandfineApiError = class extends Error {
21
371
  };
22
372
  var INSTALLED_MARKER = "data-brandfine-analytics";
23
373
  var LIVE_CHAT_MARKER = "data-brandfine-live-chat";
374
+ var LIVE_CHAT_IDENTITY_CONTEXT = "brandfine:live-chat:identity:v1";
375
+ async function hmacHex(key, message) {
376
+ const enc = new TextEncoder();
377
+ const cryptoKey = await globalThis.crypto.subtle.importKey(
378
+ "raw",
379
+ enc.encode(key),
380
+ { name: "HMAC", hash: "SHA-256" },
381
+ false,
382
+ ["sign"]
383
+ );
384
+ const sig = await globalThis.crypto.subtle.sign(
385
+ "HMAC",
386
+ cryptoKey,
387
+ enc.encode(message)
388
+ );
389
+ return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
390
+ }
24
391
  var GTAG_MARKER = "data-brandfine-gtag";
25
392
  function injectGoogleTag(measurementId) {
26
393
  if (typeof document === "undefined") return;
@@ -168,6 +535,28 @@ function createBrandfineClient(config) {
168
535
  return { installed: true, websiteId: cfg.websiteId };
169
536
  }
170
537
  };
538
+ async function liveChatWireRequest(key, method, path, body) {
539
+ const url = `${baseUrl}${path}`;
540
+ const res = await fetchImpl(url, {
541
+ method,
542
+ headers: {
543
+ Accept: "application/json",
544
+ ...body !== void 0 ? { "Content-Type": "application/json" } : {},
545
+ ...key ? { "X-Api-Key": key } : {}
546
+ },
547
+ body: body !== void 0 ? JSON.stringify(body) : void 0
548
+ });
549
+ if (!res.ok) {
550
+ const text = await res.text().catch(() => "");
551
+ throw new BrandfineApiError({
552
+ status: res.status,
553
+ statusText: res.statusText,
554
+ body: text,
555
+ url
556
+ });
557
+ }
558
+ return await res.json();
559
+ }
171
560
  const liveChat = {
172
561
  getConfig() {
173
562
  return get("/external/live-chat/bootstrap");
@@ -192,6 +581,9 @@ function createBrandfineClient(config) {
192
581
  if (opts.visitor?.externalId && opts.visitor.identityToken) {
193
582
  host.setAttribute("data-visitor", JSON.stringify(opts.visitor));
194
583
  }
584
+ if (opts.locale) {
585
+ host.setAttribute("data-locale", opts.locale);
586
+ }
195
587
  if (cfg.theme) {
196
588
  for (const [key, value] of Object.entries(cfg.theme)) {
197
589
  if (key.startsWith("--bf-chat-")) {
@@ -207,35 +599,67 @@ function createBrandfineClient(config) {
207
599
  document.head.appendChild(script);
208
600
  return { installed: true };
209
601
  },
602
+ createSession(opts) {
603
+ const pk = opts.config.enabled ? opts.config.publishableKey : void 0;
604
+ const wire = {
605
+ runtimeConfig: (locale) => liveChatWireRequest(
606
+ pk,
607
+ "GET",
608
+ `/external/live-chat/config${locale ? `?locale=${encodeURIComponent(locale)}` : ""}`
609
+ ),
610
+ startConversation: (input) => liveChatWireRequest(pk, "POST", "/external/live-chat/conversations", input),
611
+ sendMessage: (conversationToken, input) => liveChatWireRequest(
612
+ void 0,
613
+ "POST",
614
+ `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages`,
615
+ input
616
+ ),
617
+ history: (conversationToken, o = {}) => liveChatWireRequest(
618
+ void 0,
619
+ "GET",
620
+ `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${o.afterEvent !== void 0 ? `?afterEvent=${encodeURIComponent(String(o.afterEvent))}` : o.after ? `?after=${encodeURIComponent(o.after)}` : ""}`
621
+ )
622
+ };
623
+ return createLiveChatSession(wire, opts);
624
+ },
625
+ runtimeConfig(locale) {
626
+ return liveChatWireRequest(
627
+ apiKey,
628
+ "GET",
629
+ `/external/live-chat/config${locale ? `?locale=${encodeURIComponent(locale)}` : ""}`
630
+ );
631
+ },
632
+ startConversation(input) {
633
+ return liveChatWireRequest(apiKey, "POST", "/external/live-chat/conversations", input);
634
+ },
635
+ sendMessage(conversationToken, input) {
636
+ return liveChatWireRequest(
637
+ void 0,
638
+ "POST",
639
+ `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages`,
640
+ input
641
+ );
642
+ },
643
+ history(conversationToken, o = {}) {
644
+ const qs = o.afterEvent !== void 0 ? `?afterEvent=${encodeURIComponent(String(o.afterEvent))}` : o.after ? `?after=${encodeURIComponent(o.after)}` : "";
645
+ return liveChatWireRequest(
646
+ void 0,
647
+ "GET",
648
+ `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${qs}`
649
+ );
650
+ },
210
651
  async identityToken(externalId, opts = {}) {
211
652
  if (typeof document !== "undefined" || typeof window !== "undefined") {
212
653
  throw new Error(
213
654
  "liveChat.identityToken() is server-only \u2014 never compute identity tokens in a browser. Sign the visitor on your server and pass the result to install({ visitor })."
214
655
  );
215
656
  }
216
- const secret = opts.secret ?? (typeof process !== "undefined" ? process.env.BRANDFINE_LIVE_CHAT_IDENTITY_SECRET : void 0);
217
- if (!secret) {
218
- throw new Error(
219
- "liveChat.identityToken(): identity secret missing. Pass { secret } or set BRANDFINE_LIVE_CHAT_IDENTITY_SECRET. Generate one in the CMS: Plugins \u2192 Live Chat \u2192 Integrate."
220
- );
221
- }
222
657
  if (!externalId) {
223
658
  throw new Error("liveChat.identityToken(): externalId is required.");
224
659
  }
225
- const enc = new TextEncoder();
226
- const key = await globalThis.crypto.subtle.importKey(
227
- "raw",
228
- enc.encode(secret),
229
- { name: "HMAC", hash: "SHA-256" },
230
- false,
231
- ["sign"]
232
- );
233
- const sig = await globalThis.crypto.subtle.sign(
234
- "HMAC",
235
- key,
236
- enc.encode(externalId)
237
- );
238
- return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
660
+ const explicit = opts.secret ?? (typeof process !== "undefined" ? process.env.BRANDFINE_LIVE_CHAT_IDENTITY_SECRET : void 0);
661
+ const secret = explicit ?? await hmacHex(apiKey, LIVE_CHAT_IDENTITY_CONTEXT);
662
+ return hmacHex(secret, externalId);
239
663
  }
240
664
  };
241
665
  const submissions = {
@@ -303,6 +727,35 @@ function createBrandfineClient(config) {
303
727
  "/external/appointments/requests",
304
728
  input
305
729
  );
730
+ },
731
+ async list(identity) {
732
+ const res = await post(
733
+ "/external/appointments/requests/lookup",
734
+ identity
735
+ );
736
+ return res.appointments;
737
+ },
738
+ async get(id, identity) {
739
+ const res = await post(
740
+ "/external/appointments/requests/lookup",
741
+ { ...identity, id }
742
+ );
743
+ const row = res.appointments[0];
744
+ if (!row) {
745
+ throw new BrandfineApiError({
746
+ status: 404,
747
+ statusText: "Not Found",
748
+ body: "Appointment not found.",
749
+ url: "/external/appointments/requests/lookup"
750
+ });
751
+ }
752
+ return row;
753
+ },
754
+ cancel(cancellationToken) {
755
+ return post(
756
+ `/external/appointments/requests/${encodeURIComponent(cancellationToken)}/cancel`,
757
+ {}
758
+ );
306
759
  }
307
760
  };
308
761
  return {