@brandfine/client 0.13.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.d.ts CHANGED
@@ -23,6 +23,9 @@ type ChatMessage = {
23
23
  body: string;
24
24
  sender: ChatMessageSender;
25
25
  createdAt: string;
26
+ /** Monotonic per-conversation sequence — the realtime resume
27
+ * cursor. Optional: older APIs don't send it. */
28
+ eventId?: number;
26
29
  };
27
30
  type ConversationStatus = 'OPEN' | 'CLOSED';
28
31
  type LiveChatSessionState = 'CONNECTING' | 'OPEN' | 'CLOSED' | 'ERROR';
@@ -38,6 +41,10 @@ type LiveChatRuntimeConfig = {
38
41
  offlineMessage: string | null;
39
42
  theme: Record<string, string> | null;
40
43
  online?: boolean;
44
+ /** Realtime endpoint (wss://…). Absent = poll (older API, or
45
+ * the transport is off). The session upgrades automatically
46
+ * when present; consumers never touch it. */
47
+ realtimeUrl?: string;
41
48
  };
42
49
  type LiveChatSessionSnapshot = {
43
50
  messages: ChatMessage[];
@@ -64,10 +71,12 @@ type StartConversationResult = {
64
71
  };
65
72
  type LiveChatCreateSessionOptions = {
66
73
  /** The server-half bootstrap (same object `install()` takes) —
67
- * supplies the publishable key. */
74
+ * supplies the publishable key (and, when the API advertises it,
75
+ * the realtime endpoint — passed through wholesale). */
68
76
  config: {
69
77
  enabled: boolean;
70
78
  publishableKey?: string;
79
+ realtimeUrl?: string;
71
80
  };
72
81
  /** Signed identity — SAME shape and rules as `install()`. A bad
73
82
  * signature downgrades to anonymous server-side; it is never a
@@ -259,6 +268,14 @@ type CreateAppointmentRequestInput = {
259
268
  requestedAt: string;
260
269
  /** Optional cookie-derived session id from the consumer site. */
261
270
  visitorSessionId?: string;
271
+ /** Signed host-app identity — the SAME payload shape and signature
272
+ * Live Chat accepts, so one `liveChat.identityToken(externalId)`
273
+ * call signs for both plugins. When present and valid, the booking
274
+ * is attributed to the person (verified name/email take precedence
275
+ * over the free-text fields above, and the booking links to their
276
+ * chat threads via the shared externalId). Invalid or absent →
277
+ * the booking proceeds anonymously — it is never rejected. */
278
+ visitor?: VerifiedVisitor;
262
279
  };
263
280
  type CreatedAppointmentRequest = {
264
281
  id: string;
@@ -266,11 +283,40 @@ type CreatedAppointmentRequest = {
266
283
  requestedAt: string;
267
284
  durationMinutes: number;
268
285
  status: 'PENDING';
286
+ /** True iff `visitor.identityToken` HMAC-verified — your signal
287
+ * that the booking landed attributed rather than anonymous. */
288
+ identityVerified: boolean;
269
289
  /** Visitor's self-cancel token. Embed it in confirmation
270
290
  * emails / on-page UI so the visitor can cancel without an
271
291
  * account. One-time use; revoked once any party acts. */
272
292
  cancellationToken: string | null;
273
293
  };
294
+ type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'REJECTED' | 'CANCELLED';
295
+ /** One appointment as the identity-scoped read-back returns it —
296
+ * the visitor-safe projection ("where does my request stand?"). */
297
+ type Appointment = {
298
+ id: string;
299
+ status: AppointmentStatus;
300
+ /** UTC ISO 8601 of the requested slot start (reflects the current
301
+ * slot after a reschedule). */
302
+ requestedAt: string;
303
+ /** The confirmed slot — non-null only once CONFIRMED. */
304
+ scheduledAt: string | null;
305
+ durationMinutes: number;
306
+ /** Workspace's IANA timezone for local rendering. */
307
+ timezone: string;
308
+ /** Customer's note — populated only on REJECTED. */
309
+ declineReason: string | null;
310
+ rescheduleCount: number;
311
+ respondedAt: string | null;
312
+ createdAt: string;
313
+ };
314
+ /** Proof of identity for read-back calls: the same externalId +
315
+ * `liveChat.identityToken(externalId)` pair used when booking. */
316
+ type AppointmentIdentity = {
317
+ externalId: string;
318
+ identityToken: string;
319
+ };
274
320
  type AppointmentsApi = {
275
321
  /**
276
322
  * Available slots for the workspace's booking window.
@@ -286,12 +332,37 @@ type AppointmentsApi = {
286
332
  * the slot is still bookable; if it isn't, throws
287
333
  * `BrandfineApiError` with status 404 / 409.
288
334
  *
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.
335
+ * Pass `visitor` (signed with `liveChat.identityToken()`) to book
336
+ * as a known person that unlocks `list`/`get` read-back below.
337
+ * Anonymous bookings remain fully supported; their status flow
338
+ * stays email-driven via the cancellation-token link.
293
339
  */
294
340
  createRequest: (input: CreateAppointmentRequestInput) => Promise<CreatedAppointmentRequest>;
341
+ /**
342
+ * Every appointment belonging to the verified person, newest
343
+ * first. Requires a valid identity signature — throws
344
+ * `BrandfineApiError` 401 on a bad one (reads need proof; there
345
+ * is no anonymous downgrade for reading history). Server-side
346
+ * only, like `identityToken()` itself.
347
+ */
348
+ list: (identity: AppointmentIdentity) => Promise<Appointment[]>;
349
+ /**
350
+ * One appointment by id, identity-scoped. 404s when the id does
351
+ * not exist OR belongs to someone else — indistinguishable by
352
+ * design.
353
+ */
354
+ get: (id: string, identity: AppointmentIdentity) => Promise<Appointment>;
355
+ /**
356
+ * Spend the one-time `cancellationToken` from `createRequest` to
357
+ * cancel a still-PENDING request. The token IS the credential —
358
+ * no id or API key needed. Throws `BrandfineApiError` 400 when
359
+ * the request is no longer PENDING, 404 when the token is
360
+ * unknown/already spent.
361
+ */
362
+ cancel: (cancellationToken: string) => Promise<{
363
+ id: string;
364
+ status: 'CANCELLED';
365
+ }>;
295
366
  };
296
367
  type AnalyticsConfig = {
297
368
  enabled: false;
@@ -463,6 +534,12 @@ type LiveChatBootstrap = {
463
534
  theme: Record<string, string> | null;
464
535
  /** Widget bundle path relative to the API base URL. */
465
536
  scriptPath: string;
537
+ /** Realtime endpoint (wss://…). OPTIONAL by design — this is
538
+ * what makes the upgrade non-breaking: an older SDK ignores
539
+ * it, a newer SDK against an older API sees it absent and
540
+ * polls. Consumers pass `config` through wholesale, so it
541
+ * reaches the browser with no change on their side. */
542
+ realtimeUrl?: string;
466
543
  };
467
544
  type LiveChatInstallResult = {
468
545
  installed: false;
@@ -478,6 +555,13 @@ type LiveChatInstallResult = {
478
555
  * the Brandfine API verifies the signature. An invalid or missing
479
556
  * token silently downgrades the conversation to anonymous.
480
557
  */
558
+ /**
559
+ * A signed host-app identity payload. Named per-plugin below for
560
+ * discoverability, but it is ONE shape signed ONE way: a token from
561
+ * `liveChat.identityToken(externalId)` is accepted by Live Chat AND
562
+ * Appointments (`createRequest.visitor`, `list`/`get`).
563
+ */
564
+ type VerifiedVisitor = LiveChatVisitor;
481
565
  type LiveChatVisitor = {
482
566
  /** Your app's stable id for this person (user id, lead reference…).
483
567
  * Conversations sharing an externalId are the same person across
@@ -588,9 +672,11 @@ type LiveChatApi = {
588
672
  }) => Promise<StartConversationResult>;
589
673
  sendMessage: (conversationToken: string, input: {
590
674
  body: string;
675
+ clientId?: string;
591
676
  }) => Promise<ChatMessage>;
592
677
  history: (conversationToken: string, opts?: {
593
678
  after?: string;
679
+ afterEvent?: number;
594
680
  }) => Promise<{
595
681
  messages: ChatMessage[];
596
682
  status: ConversationStatus;
@@ -612,4 +698,4 @@ declare function createBrandfineClient(config: BrandfineClientConfig): Brandfine
612
698
  */
613
699
  declare const SDK_VERSION: "0.0.0";
614
700
 
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 };
701
+ 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 {