@brandfine/client 0.13.0 → 0.14.1

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.d.ts CHANGED
@@ -17,12 +17,23 @@ export { BrandfineWebhookEvent, BrandfineWebhookHandlerOptions, BrandfineWebhook
17
17
  * who talks through the widget on one page and an inline panel on
18
18
  * another continues the same thread.
19
19
  */
20
- type ChatMessageSender = 'VISITOR' | 'AGENT' | 'SYSTEM';
20
+ /**
21
+ * `BOT` = AI-assistant replies (arriving once a workspace enables the
22
+ * live-chat AI agent). Render `BOT` on the business/agent side, ideally
23
+ * with an "AI" label — and treat any FUTURE unknown sender value the
24
+ * same way (agent-side, generic label) rather than switching
25
+ * exhaustively: the server may add sender kinds before your copy of
26
+ * this SDK learns their names.
27
+ */
28
+ type ChatMessageSender = 'VISITOR' | 'AGENT' | 'SYSTEM' | 'BOT';
21
29
  type ChatMessage = {
22
30
  id: string;
23
31
  body: string;
24
32
  sender: ChatMessageSender;
25
33
  createdAt: string;
34
+ /** Monotonic per-conversation sequence — the realtime resume
35
+ * cursor. Optional: older APIs don't send it. */
36
+ eventId?: number;
26
37
  };
27
38
  type ConversationStatus = 'OPEN' | 'CLOSED';
28
39
  type LiveChatSessionState = 'CONNECTING' | 'OPEN' | 'CLOSED' | 'ERROR';
@@ -38,6 +49,10 @@ type LiveChatRuntimeConfig = {
38
49
  offlineMessage: string | null;
39
50
  theme: Record<string, string> | null;
40
51
  online?: boolean;
52
+ /** Realtime endpoint (wss://…). Absent = poll (older API, or
53
+ * the transport is off). The session upgrades automatically
54
+ * when present; consumers never touch it. */
55
+ realtimeUrl?: string;
41
56
  };
42
57
  type LiveChatSessionSnapshot = {
43
58
  messages: ChatMessage[];
@@ -64,10 +79,12 @@ type StartConversationResult = {
64
79
  };
65
80
  type LiveChatCreateSessionOptions = {
66
81
  /** The server-half bootstrap (same object `install()` takes) —
67
- * supplies the publishable key. */
82
+ * supplies the publishable key (and, when the API advertises it,
83
+ * the realtime endpoint — passed through wholesale). */
68
84
  config: {
69
85
  enabled: boolean;
70
86
  publishableKey?: string;
87
+ realtimeUrl?: string;
71
88
  };
72
89
  /** Signed identity — SAME shape and rules as `install()`. A bad
73
90
  * signature downgrades to anonymous server-side; it is never a
@@ -259,6 +276,14 @@ type CreateAppointmentRequestInput = {
259
276
  requestedAt: string;
260
277
  /** Optional cookie-derived session id from the consumer site. */
261
278
  visitorSessionId?: string;
279
+ /** Signed host-app identity — the SAME payload shape and signature
280
+ * Live Chat accepts, so one `liveChat.identityToken(externalId)`
281
+ * call signs for both plugins. When present and valid, the booking
282
+ * is attributed to the person (verified name/email take precedence
283
+ * over the free-text fields above, and the booking links to their
284
+ * chat threads via the shared externalId). Invalid or absent →
285
+ * the booking proceeds anonymously — it is never rejected. */
286
+ visitor?: VerifiedVisitor;
262
287
  };
263
288
  type CreatedAppointmentRequest = {
264
289
  id: string;
@@ -266,11 +291,40 @@ type CreatedAppointmentRequest = {
266
291
  requestedAt: string;
267
292
  durationMinutes: number;
268
293
  status: 'PENDING';
294
+ /** True iff `visitor.identityToken` HMAC-verified — your signal
295
+ * that the booking landed attributed rather than anonymous. */
296
+ identityVerified: boolean;
269
297
  /** Visitor's self-cancel token. Embed it in confirmation
270
298
  * emails / on-page UI so the visitor can cancel without an
271
299
  * account. One-time use; revoked once any party acts. */
272
300
  cancellationToken: string | null;
273
301
  };
302
+ type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'REJECTED' | 'CANCELLED';
303
+ /** One appointment as the identity-scoped read-back returns it —
304
+ * the visitor-safe projection ("where does my request stand?"). */
305
+ type Appointment = {
306
+ id: string;
307
+ status: AppointmentStatus;
308
+ /** UTC ISO 8601 of the requested slot start (reflects the current
309
+ * slot after a reschedule). */
310
+ requestedAt: string;
311
+ /** The confirmed slot — non-null only once CONFIRMED. */
312
+ scheduledAt: string | null;
313
+ durationMinutes: number;
314
+ /** Workspace's IANA timezone for local rendering. */
315
+ timezone: string;
316
+ /** Customer's note — populated only on REJECTED. */
317
+ declineReason: string | null;
318
+ rescheduleCount: number;
319
+ respondedAt: string | null;
320
+ createdAt: string;
321
+ };
322
+ /** Proof of identity for read-back calls: the same externalId +
323
+ * `liveChat.identityToken(externalId)` pair used when booking. */
324
+ type AppointmentIdentity = {
325
+ externalId: string;
326
+ identityToken: string;
327
+ };
274
328
  type AppointmentsApi = {
275
329
  /**
276
330
  * Available slots for the workspace's booking window.
@@ -286,12 +340,37 @@ type AppointmentsApi = {
286
340
  * the slot is still bookable; if it isn't, throws
287
341
  * `BrandfineApiError` with status 404 / 409.
288
342
  *
289
- * The visitor's browser does not have any other appointment
290
- * actions in v1post-submission status changes (approve /
291
- * decline / reschedule) happen via email, driven by the
292
- * customer in the CMS.
343
+ * Pass `visitor` (signed with `liveChat.identityToken()`) to book
344
+ * as a known person that unlocks `list`/`get` read-back below.
345
+ * Anonymous bookings remain fully supported; their status flow
346
+ * stays email-driven via the cancellation-token link.
293
347
  */
294
348
  createRequest: (input: CreateAppointmentRequestInput) => Promise<CreatedAppointmentRequest>;
349
+ /**
350
+ * Every appointment belonging to the verified person, newest
351
+ * first. Requires a valid identity signature — throws
352
+ * `BrandfineApiError` 401 on a bad one (reads need proof; there
353
+ * is no anonymous downgrade for reading history). Server-side
354
+ * only, like `identityToken()` itself.
355
+ */
356
+ list: (identity: AppointmentIdentity) => Promise<Appointment[]>;
357
+ /**
358
+ * One appointment by id, identity-scoped. 404s when the id does
359
+ * not exist OR belongs to someone else — indistinguishable by
360
+ * design.
361
+ */
362
+ get: (id: string, identity: AppointmentIdentity) => Promise<Appointment>;
363
+ /**
364
+ * Spend the one-time `cancellationToken` from `createRequest` to
365
+ * cancel a still-PENDING request. The token IS the credential —
366
+ * no id or API key needed. Throws `BrandfineApiError` 400 when
367
+ * the request is no longer PENDING, 404 when the token is
368
+ * unknown/already spent.
369
+ */
370
+ cancel: (cancellationToken: string) => Promise<{
371
+ id: string;
372
+ status: 'CANCELLED';
373
+ }>;
295
374
  };
296
375
  type AnalyticsConfig = {
297
376
  enabled: false;
@@ -463,6 +542,12 @@ type LiveChatBootstrap = {
463
542
  theme: Record<string, string> | null;
464
543
  /** Widget bundle path relative to the API base URL. */
465
544
  scriptPath: string;
545
+ /** Realtime endpoint (wss://…). OPTIONAL by design — this is
546
+ * what makes the upgrade non-breaking: an older SDK ignores
547
+ * it, a newer SDK against an older API sees it absent and
548
+ * polls. Consumers pass `config` through wholesale, so it
549
+ * reaches the browser with no change on their side. */
550
+ realtimeUrl?: string;
466
551
  };
467
552
  type LiveChatInstallResult = {
468
553
  installed: false;
@@ -478,6 +563,13 @@ type LiveChatInstallResult = {
478
563
  * the Brandfine API verifies the signature. An invalid or missing
479
564
  * token silently downgrades the conversation to anonymous.
480
565
  */
566
+ /**
567
+ * A signed host-app identity payload. Named per-plugin below for
568
+ * discoverability, but it is ONE shape signed ONE way: a token from
569
+ * `liveChat.identityToken(externalId)` is accepted by Live Chat AND
570
+ * Appointments (`createRequest.visitor`, `list`/`get`).
571
+ */
572
+ type VerifiedVisitor = LiveChatVisitor;
481
573
  type LiveChatVisitor = {
482
574
  /** Your app's stable id for this person (user id, lead reference…).
483
575
  * Conversations sharing an externalId are the same person across
@@ -588,9 +680,11 @@ type LiveChatApi = {
588
680
  }) => Promise<StartConversationResult>;
589
681
  sendMessage: (conversationToken: string, input: {
590
682
  body: string;
683
+ clientId?: string;
591
684
  }) => Promise<ChatMessage>;
592
685
  history: (conversationToken: string, opts?: {
593
686
  after?: string;
687
+ afterEvent?: number;
594
688
  }) => Promise<{
595
689
  messages: ChatMessage[];
596
690
  status: ConversationStatus;
@@ -612,4 +706,4 @@ declare function createBrandfineClient(config: BrandfineClientConfig): Brandfine
612
706
  */
613
707
  declare const SDK_VERSION: "0.0.0";
614
708
 
615
- export { type AnalyticsConfig, type AnalyticsInstallResult, type AnalyticsOverview, type AnalyticsOverviewRange, BrandfineApiError, BrandfineCategory, type BrandfineClient, type BrandfineClientConfig, BrandfineNavigation, BrandfinePost, BrandfineWorkspace, type ChatMessage, type ChatMessageSender, type ConversationStatus, type CreateSubmissionInput, type InstallOptions, ListCategoriesOptions, ListPostsOptions, type LiveChatBootstrap, type LiveChatCreateSessionOptions, type LiveChatInstallOptions, type LiveChatInstallResult, type LiveChatRuntimeConfig, type LiveChatSession, type LiveChatSessionSnapshot, type LiveChatSessionState, type LiveChatVisitor, SDK_VERSION, type StartConversationResult, type Submission, createBrandfineClient };
709
+ export { type AnalyticsConfig, type AnalyticsInstallResult, type AnalyticsOverview, type AnalyticsOverviewRange, type Appointment, type AppointmentAvailability, type AppointmentIdentity, type AppointmentSlot, type AppointmentStatus, BrandfineApiError, BrandfineCategory, type BrandfineClient, type BrandfineClientConfig, BrandfineNavigation, BrandfinePost, BrandfineWorkspace, type ChatMessage, type ChatMessageSender, type ConversationStatus, type CreateAppointmentRequestInput, type CreateSubmissionInput, type CreatedAppointmentRequest, type InstallOptions, ListCategoriesOptions, ListPostsOptions, type LiveChatBootstrap, type LiveChatCreateSessionOptions, type LiveChatInstallOptions, type LiveChatInstallResult, type LiveChatRuntimeConfig, type LiveChatSession, type LiveChatSessionSnapshot, type LiveChatSessionState, type LiveChatVisitor, SDK_VERSION, type StartConversationResult, type Submission, type VerifiedVisitor, createBrandfineClient };
package/dist/index.js CHANGED
@@ -60,34 +60,63 @@ async function createLiveChatSession(wire, opts) {
60
60
  const listeners = /* @__PURE__ */ new Set();
61
61
  const seen = /* @__PURE__ */ new Set();
62
62
  let cursor;
63
+ let lastEventId = 0;
63
64
  let token;
64
65
  let timer;
65
66
  let closed = false;
66
67
  let ticking = false;
67
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;
68
76
  function emit(patch) {
69
77
  snapshot = { ...snapshot, ...patch };
70
78
  for (const listener of listeners) listener(snapshot);
71
79
  }
72
80
  function appendMessages(incoming) {
73
81
  const fresh = incoming.filter((m) => !seen.has(m.id));
74
- if (fresh.length === 0) return;
75
- for (const m of fresh) seen.add(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
+ }
76
96
  const messages = [...snapshot.messages, ...fresh];
77
97
  cursor = messages[messages.length - 1].createdAt;
78
98
  emit({ messages });
79
99
  }
100
+ function markClosed() {
101
+ if (snapshot.status !== "CLOSED") emit({ status: "CLOSED" });
102
+ stopPolling();
103
+ teardownRealtime();
104
+ wsGivenUp = true;
105
+ }
80
106
  async function tick() {
81
107
  if (ticking || closed || !token) return;
82
108
  ticking = true;
83
109
  try {
84
- const result = await wire.history(token, { after: cursor });
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
+ );
85
117
  if (closed) return;
86
118
  appendMessages(result.messages);
87
- if (result.status === "CLOSED" && snapshot.status !== "CLOSED") {
88
- emit({ status: "CLOSED" });
89
- stopPolling();
90
- }
119
+ if (result.status === "CLOSED") markClosed();
91
120
  } catch {
92
121
  } finally {
93
122
  ticking = false;
@@ -101,6 +130,119 @@ async function createLiveChatSession(wire, opts) {
101
130
  if (timer !== void 0) clearInterval(timer);
102
131
  timer = void 0;
103
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
+ }
104
246
  async function connect(resumeToken) {
105
247
  if (!publishableKey) {
106
248
  emit({ status: "CLOSED" });
@@ -118,6 +260,7 @@ async function createLiveChatSession(wire, opts) {
118
260
  offlineMessage: cfg.offlineMessage,
119
261
  online: cfg.online !== false
120
262
  });
263
+ if (cfg.realtimeUrl !== void 0) realtimeUrl = cfg.realtimeUrl;
121
264
  } catch (error) {
122
265
  if (closed) return;
123
266
  emit({ status: "ERROR", error });
@@ -142,7 +285,10 @@ async function createLiveChatSession(wire, opts) {
142
285
  if (started.online !== void 0) emit({ online: started.online });
143
286
  emit({ status: "OPEN" });
144
287
  await tick();
145
- if (!closed) startPolling();
288
+ if (!closed) {
289
+ startPolling();
290
+ startRealtime();
291
+ }
146
292
  } catch (error) {
147
293
  if (closed) return;
148
294
  emit({ status: "ERROR", error });
@@ -169,7 +315,11 @@ async function createLiveChatSession(wire, opts) {
169
315
  }
170
316
  emit({ sending: true });
171
317
  try {
172
- const message = await wire.sendMessage(token, { body: trimmed });
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
+ });
173
323
  appendMessages([message]);
174
324
  return message;
175
325
  } finally {
@@ -180,13 +330,18 @@ async function createLiveChatSession(wire, opts) {
180
330
  if (closed) return;
181
331
  closed = true;
182
332
  stopPolling();
333
+ teardownRealtime();
183
334
  aborter.abort();
184
335
  listeners.clear();
185
336
  },
186
337
  async reset() {
338
+ teardownRealtime();
339
+ wsFailures = 0;
340
+ wsGivenUp = false;
187
341
  if (publishableKey) writeStoredToken(publishableKey, null);
188
342
  token = void 0;
189
343
  cursor = void 0;
344
+ lastEventId = 0;
190
345
  seen.clear();
191
346
  stopPolling();
192
347
  emit({ messages: [], status: "CONNECTING", error: null });
@@ -462,7 +617,7 @@ function createBrandfineClient(config) {
462
617
  history: (conversationToken, o = {}) => liveChatWireRequest(
463
618
  void 0,
464
619
  "GET",
465
- `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${o.after ? `?after=${encodeURIComponent(o.after)}` : ""}`
620
+ `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${o.afterEvent !== void 0 ? `?afterEvent=${encodeURIComponent(String(o.afterEvent))}` : o.after ? `?after=${encodeURIComponent(o.after)}` : ""}`
466
621
  )
467
622
  };
468
623
  return createLiveChatSession(wire, opts);
@@ -486,10 +641,11 @@ function createBrandfineClient(config) {
486
641
  );
487
642
  },
488
643
  history(conversationToken, o = {}) {
644
+ const qs = o.afterEvent !== void 0 ? `?afterEvent=${encodeURIComponent(String(o.afterEvent))}` : o.after ? `?after=${encodeURIComponent(o.after)}` : "";
489
645
  return liveChatWireRequest(
490
646
  void 0,
491
647
  "GET",
492
- `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${o.after ? `?after=${encodeURIComponent(o.after)}` : ""}`
648
+ `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${qs}`
493
649
  );
494
650
  },
495
651
  async identityToken(externalId, opts = {}) {
@@ -571,6 +727,35 @@ function createBrandfineClient(config) {
571
727
  "/external/appointments/requests",
572
728
  input
573
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
+ );
574
759
  }
575
760
  };
576
761
  return {