@pocketboss/core 0.1.0 → 0.2.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 CHANGED
@@ -39,3 +39,29 @@ The bridge methods `client.pickFiles()` and `client.capturePhoto()` remain separ
39
39
  The client validates v1 messages and method payloads against generated copies of `packages/bridge-contract/schemas`, enforces message and pending-request limits, correlates random UUID requests, checks launch binding and event sequence, and cancels work on abort, timeout, pause and teardown. Native still owns sender verification, permission checks, and launch-ticket exchange.
40
40
 
41
41
  Run `npm run build:core` and `npm test -w @pocketboss/core` from the repository root. Regenerate contract types before every build.
42
+
43
+ ## Business order comments
44
+
45
+ The core comments client/custom element works in vanilla JS and is the shared implementation used by every framework wrapper. Give it the PocketBoss order ULID, a workspace key, and session callbacks that call your same-origin Mini App backend. The backend owns business authorization and integration credentials; the browser receives only a short-lived, scoped comments token.
46
+
47
+ ```ts
48
+ import { registerOrderCommentsElement } from '@pocketboss/core/comments';
49
+ import type { OrderCommentMessage, PocketBossOrderCommentsElement } from '@pocketboss/core/comments';
50
+
51
+ registerOrderCommentsElement();
52
+ const element = document.querySelector('pb-order-comments') as PocketBossOrderCommentsElement;
53
+ element.options = {
54
+ apiBaseUrl: 'https://api.example.test/api/v1',
55
+ orderId: pocketBossOrderId,
56
+ workspaceKey: businessId,
57
+ sessions, // create/revoke via same-origin /api/business/order-comments/session
58
+ placement: 'inline', // modal, bottom-sheet, full-page, or headless also work
59
+ renderMessage(message: OrderCommentMessage) {
60
+ const bubble = document.createElement('article');
61
+ bubble.textContent = message.text; // user-authored text: do not use innerHTML
62
+ return bubble;
63
+ },
64
+ };
65
+ ```
66
+
67
+ See the [end-to-end SDK guide](../../docs/SDK_GUIDE.md#registration-to-comments-fixture) and [sample backend](../../examples/sample-mini-app-backend/README.md) for access modes, registration, server-side customer assertion verification, order sync, and session lifecycle. `renderMessage` is optional; omitting it uses the default accessible renderer.
@@ -0,0 +1,2 @@
1
+ export * from '../comments.js';
2
+ export * from '../comments-element.js';
@@ -0,0 +1,2 @@
1
+ export * from '../comments.js';
2
+ export * from '../comments-element.js';
@@ -0,0 +1,37 @@
1
+ import { OrderCommentsClient } from './comments.js';
2
+ import type { OrderCommentMessage, OrderCommentsClientOptions } from './comments.js';
3
+ export type OrderCommentsPlacement = 'inline' | 'modal' | 'bottom-sheet' | 'full-page' | 'headless';
4
+ export type OrderCommentsThemeMode = 'light' | 'dark' | 'auto';
5
+ export interface OrderCommentsTheme {
6
+ mode?: OrderCommentsThemeMode;
7
+ accent?: string;
8
+ surface?: string;
9
+ text?: string;
10
+ muted?: string;
11
+ border?: string;
12
+ customerBubble?: string;
13
+ customerText?: string;
14
+ businessBubble?: string;
15
+ businessText?: string;
16
+ fontFamily?: string;
17
+ spacing?: number;
18
+ radius?: number;
19
+ }
20
+ export interface OrderCommentsElementOptions extends OrderCommentsClientOptions {
21
+ workspaceKey: string;
22
+ placement?: OrderCommentsPlacement;
23
+ theme?: OrderCommentsTheme;
24
+ title?: string;
25
+ language?: string;
26
+ /** Return a DOM node for a message. Use textContent for untrusted message text. */
27
+ renderMessage?: (message: OrderCommentMessage) => HTMLElement;
28
+ }
29
+ export interface PocketBossOrderCommentsElement extends HTMLElement {
30
+ options: OrderCommentsElementOptions;
31
+ readonly commentsClient: OrderCommentsClient | null;
32
+ loadComments(): Promise<void>;
33
+ openCommentsClient(): Promise<OrderCommentsClient>;
34
+ }
35
+ /** Register the framework-neutral comments element. Safe to call during SSR. */
36
+ export declare function registerOrderCommentsElement(tagName?: string): void;
37
+ export declare function mountOrderCommentsElement(element: HTMLElement, options: OrderCommentsElementOptions): PocketBossOrderCommentsElement;
@@ -0,0 +1,468 @@
1
+ import { OrderCommentsClient, OrderCommentsError, } from './comments.js';
2
+ const defaultTagName = 'pb-order-comments';
3
+ let commentsCloseBarrier = Promise.resolve();
4
+ /** Register the framework-neutral comments element. Safe to call during SSR. */
5
+ export function registerOrderCommentsElement(tagName = defaultTagName) {
6
+ if (typeof window === 'undefined' || !('customElements' in window))
7
+ return;
8
+ if (customElements.get(tagName))
9
+ return;
10
+ class PocketBossOrderComments extends HTMLElement {
11
+ root;
12
+ currentOptions = null;
13
+ currentClient = null;
14
+ pendingClose = Promise.resolve();
15
+ messages = [];
16
+ nextCursor = null;
17
+ unreadCount = 0;
18
+ loading = false;
19
+ sending = false;
20
+ error = null;
21
+ expired = false;
22
+ opened = false;
23
+ draft = '';
24
+ pendingText = null;
25
+ pendingId = null;
26
+ loadSequence = 0;
27
+ lastReadMessageId = null;
28
+ constructor() {
29
+ super();
30
+ this.root = this.attachShadow({ mode: 'open' });
31
+ }
32
+ get options() {
33
+ if (!this.currentOptions)
34
+ throw new Error('Order comments options have not been set.');
35
+ return this.currentOptions;
36
+ }
37
+ set options(value) {
38
+ const old = this.currentOptions;
39
+ this.currentOptions = value;
40
+ if (old && contextKey(old) !== contextKey(value)) {
41
+ this.closeCurrentClient();
42
+ this.resetConversation();
43
+ }
44
+ this.applyTheme(value.theme ?? {});
45
+ this.render();
46
+ if (this.isConnected && isInitiallyOpen(value.placement) && !old)
47
+ void this.load();
48
+ if (this.isConnected && isInitiallyOpen(value.placement) && old && contextKey(old) !== contextKey(value))
49
+ void this.load();
50
+ }
51
+ get commentsClient() {
52
+ return this.currentClient;
53
+ }
54
+ async loadComments() {
55
+ if (this.currentOptions?.placement === 'headless') {
56
+ await this.ensureClient();
57
+ return;
58
+ }
59
+ if (this.currentOptions?.placement === 'modal' || this.currentOptions?.placement === 'bottom-sheet')
60
+ return;
61
+ await this.load();
62
+ }
63
+ openCommentsClient() {
64
+ return this.ensureClient();
65
+ }
66
+ connectedCallback() {
67
+ this.render();
68
+ if (this.currentOptions && isInitiallyOpen(this.currentOptions.placement))
69
+ void this.load();
70
+ }
71
+ disconnectedCallback() {
72
+ this.closeCurrentClient();
73
+ }
74
+ async ensureClient() {
75
+ await commentsCloseBarrier;
76
+ await this.pendingClose;
77
+ if (!this.currentOptions)
78
+ throw new OrderCommentsError('Order comments options have not been set.');
79
+ if (!this.currentClient || !this.currentClient.active) {
80
+ this.currentClient = new OrderCommentsClient(this.currentOptions);
81
+ }
82
+ return this.currentClient;
83
+ }
84
+ closeCurrentClient() {
85
+ const client = this.currentClient;
86
+ this.currentClient = null;
87
+ if (client) {
88
+ this.pendingClose = client.close();
89
+ commentsCloseBarrier = Promise.all([commentsCloseBarrier, this.pendingClose]).then(() => undefined);
90
+ void commentsCloseBarrier.catch(() => { });
91
+ }
92
+ }
93
+ resetConversation() {
94
+ this.messages = [];
95
+ this.nextCursor = null;
96
+ this.unreadCount = 0;
97
+ this.loading = false;
98
+ this.error = null;
99
+ this.expired = false;
100
+ this.pendingId = null;
101
+ this.pendingText = null;
102
+ this.lastReadMessageId = null;
103
+ this.loadSequence++;
104
+ }
105
+ async load(cursor) {
106
+ if (!this.currentOptions || this.loading)
107
+ return;
108
+ const sequence = ++this.loadSequence;
109
+ this.loading = true;
110
+ this.error = null;
111
+ this.expired = false;
112
+ this.render();
113
+ try {
114
+ const page = await (await this.ensureClient()).messages(cursor);
115
+ if (sequence !== this.loadSequence || !this.isConnected)
116
+ return;
117
+ const old = new Set(this.messages.map((message) => message.messageId));
118
+ this.messages = cursor
119
+ ? [...this.messages, ...page.messages.filter((message) => !old.has(message.messageId))]
120
+ : page.messages;
121
+ this.nextCursor = page.nextCursor;
122
+ this.unreadCount = page.unreadCount;
123
+ this.markLatestBusinessMessageRead();
124
+ }
125
+ catch (error) {
126
+ if (sequence !== this.loadSequence)
127
+ return;
128
+ this.showError(error);
129
+ }
130
+ finally {
131
+ if (sequence === this.loadSequence) {
132
+ this.loading = false;
133
+ this.render();
134
+ }
135
+ }
136
+ }
137
+ async send() {
138
+ const text = this.draft.trim();
139
+ if (!text || text.length > 5000 || this.sending)
140
+ return;
141
+ if (this.pendingText !== text || !this.pendingId) {
142
+ this.pendingText = text;
143
+ this.pendingId = secureUuid();
144
+ }
145
+ this.sending = true;
146
+ this.error = null;
147
+ this.render();
148
+ try {
149
+ const message = await (await this.ensureClient()).send(text, this.pendingId);
150
+ if (!this.isConnected)
151
+ return;
152
+ if (!this.messages.some((existing) => existing.messageId === message.messageId))
153
+ this.messages.push(message);
154
+ this.draft = '';
155
+ this.pendingId = null;
156
+ this.pendingText = null;
157
+ this.unreadCount = 0;
158
+ }
159
+ catch (error) {
160
+ this.showError(error);
161
+ }
162
+ finally {
163
+ this.sending = false;
164
+ this.render();
165
+ }
166
+ }
167
+ markLatestBusinessMessageRead() {
168
+ const last = [...this.messages].reverse().find((message) => message.participantSide === 'business');
169
+ if (!last || last.messageId === this.lastReadMessageId || !this.currentClient)
170
+ return;
171
+ this.lastReadMessageId = last.messageId;
172
+ void this.currentClient.markRead(last.messageId).catch(() => {
173
+ if (this.lastReadMessageId === last.messageId)
174
+ this.lastReadMessageId = null;
175
+ });
176
+ }
177
+ showError(value) {
178
+ const error = value instanceof OrderCommentsError ? value : null;
179
+ this.expired = error?.status === 401 || error?.status === 410 || error?.code === 'COMMENTS_SESSION_EXPIRED';
180
+ this.error = this.expired
181
+ ? 'Comments access expired. Reconnect to renew this secure session.'
182
+ : error?.status === 403 || error?.status === 404
183
+ ? 'This order is not available to the current business workspace.'
184
+ : error?.message ?? 'Order comments are unavailable. Retry in a moment.';
185
+ }
186
+ applyTheme(theme) {
187
+ const colors = [
188
+ ['accent', theme.accent],
189
+ ['surface', theme.surface],
190
+ ['text', theme.text],
191
+ ['muted', theme.muted],
192
+ ['border', theme.border],
193
+ ['customer', theme.customerBubble],
194
+ ['customer-text', theme.customerText],
195
+ ['business', theme.businessBubble],
196
+ ['business-text', theme.businessText],
197
+ ];
198
+ for (const [name, value] of colors) {
199
+ const safe = safeColor(value);
200
+ if (safe)
201
+ this.style.setProperty(`--pb-comments-${name}`, safe);
202
+ else
203
+ this.style.removeProperty(`--pb-comments-${name}`);
204
+ }
205
+ const font = safeFont(theme.fontFamily);
206
+ if (font)
207
+ this.style.setProperty('--pb-comments-font', font);
208
+ else
209
+ this.style.removeProperty('--pb-comments-font');
210
+ this.style.setProperty('--pb-comments-space', `${safeDimension(theme.spacing, 16, 6, 32)}px`);
211
+ this.style.setProperty('--pb-comments-radius', `${safeDimension(theme.radius, 16, 0, 32)}px`);
212
+ this.root.host.toggleAttribute('data-dark', theme.mode === 'dark' || (theme.mode === 'auto' && prefersDark()));
213
+ }
214
+ render() {
215
+ const options = this.currentOptions;
216
+ if (!options || options.placement === 'headless') {
217
+ this.root.replaceChildren();
218
+ return;
219
+ }
220
+ const placement = options.placement ?? 'inline';
221
+ const shell = document.createElement('div');
222
+ shell.className = `pb-shell pb-${placement}`;
223
+ if (placement === 'modal' || placement === 'bottom-sheet') {
224
+ const trigger = button(options.title ?? 'Order comments', 'open');
225
+ trigger.className = 'pb-trigger';
226
+ shell.append(trigger);
227
+ if (this.opened) {
228
+ const overlay = document.createElement('div');
229
+ overlay.className = `pb-overlay ${placement === 'bottom-sheet' ? 'pb-sheet-overlay' : ''}`;
230
+ overlay.setAttribute('role', 'presentation');
231
+ overlay.addEventListener('click', (event) => {
232
+ if (event.target === overlay)
233
+ this.closePanel();
234
+ });
235
+ const panel = this.makePanel(options.title ?? 'Order comments', true);
236
+ panel.classList.add(placement === 'bottom-sheet' ? 'pb-sheet' : 'pb-modal');
237
+ overlay.append(panel);
238
+ shell.append(overlay);
239
+ }
240
+ }
241
+ else {
242
+ shell.append(this.makePanel(options.title ?? 'Order comments', false));
243
+ }
244
+ this.root.replaceChildren(this.styles(), shell);
245
+ const open = this.root.querySelector('[data-action="open"]');
246
+ open?.addEventListener('click', () => {
247
+ this.opened = true;
248
+ this.render();
249
+ void this.load();
250
+ });
251
+ this.root.querySelector('[data-action="close"]')?.addEventListener('click', () => this.closePanel());
252
+ this.root.querySelector('[data-action="refresh"]')?.addEventListener('click', () => void this.load());
253
+ this.root.querySelector('[data-action="retry"]')?.addEventListener('click', () => void this.load());
254
+ this.root.querySelector('[data-action="more"]')?.addEventListener('click', () => {
255
+ if (this.nextCursor)
256
+ void this.load(this.nextCursor);
257
+ });
258
+ const textarea = this.root.querySelector('textarea');
259
+ if (textarea) {
260
+ textarea.value = this.draft;
261
+ textarea.addEventListener('input', () => {
262
+ this.draft = textarea.value;
263
+ const send = this.root.querySelector('[data-action="send"]');
264
+ if (send)
265
+ send.disabled = this.sending || !this.draft.trim();
266
+ if (this.pendingText !== this.draft.trim()) {
267
+ this.pendingText = null;
268
+ this.pendingId = null;
269
+ }
270
+ });
271
+ textarea.addEventListener('keydown', (event) => {
272
+ if (event.key === 'Enter' && !event.shiftKey) {
273
+ event.preventDefault();
274
+ void this.send();
275
+ }
276
+ });
277
+ }
278
+ this.root.querySelector('[data-action="send"]')?.addEventListener('click', () => void this.send());
279
+ if (!this.currentOptions?.renderMessage) {
280
+ this.root.querySelectorAll('[data-message]').forEach((node) => {
281
+ const message = this.messages.find((item) => item.messageId === node.dataset.message);
282
+ if (message)
283
+ node.textContent = message.text;
284
+ });
285
+ }
286
+ const list = this.root.querySelector('.pb-messages');
287
+ if (list && this.messages.length && !this.loading)
288
+ list.scrollTop = list.scrollHeight;
289
+ }
290
+ closePanel() {
291
+ this.opened = false;
292
+ this.closeCurrentClient();
293
+ this.resetConversation();
294
+ this.render();
295
+ }
296
+ makePanel(title, closable) {
297
+ const panel = document.createElement('section');
298
+ panel.className = 'pb-panel';
299
+ if (closable)
300
+ panel.setAttribute('role', 'dialog');
301
+ panel.setAttribute('aria-label', title);
302
+ const header = document.createElement('header');
303
+ const heading = document.createElement('strong');
304
+ heading.textContent = title;
305
+ const headerActions = document.createElement('div');
306
+ if (this.unreadCount) {
307
+ const unread = document.createElement('span');
308
+ unread.className = 'pb-unread';
309
+ unread.textContent = `${this.unreadCount} unread`;
310
+ headerActions.append(unread);
311
+ }
312
+ const refresh = button('Refresh', 'refresh');
313
+ refresh.className = 'pb-icon-button';
314
+ refresh.setAttribute('aria-label', 'Refresh comments');
315
+ headerActions.append(refresh);
316
+ if (closable) {
317
+ const close = button('×', 'close');
318
+ close.className = 'pb-icon-button';
319
+ close.setAttribute('aria-label', 'Close comments');
320
+ headerActions.append(close);
321
+ }
322
+ header.append(heading, headerActions);
323
+ panel.append(header);
324
+ if (this.loading && !this.messages.length) {
325
+ const status = document.createElement('div');
326
+ status.className = 'pb-state';
327
+ status.setAttribute('role', 'status');
328
+ status.textContent = 'Loading comments…';
329
+ panel.append(status);
330
+ }
331
+ else if (this.error) {
332
+ const error = document.createElement('div');
333
+ error.className = 'pb-error';
334
+ error.setAttribute('role', 'alert');
335
+ const copy = document.createElement('p');
336
+ copy.textContent = this.error;
337
+ const retry = button(this.expired ? 'Reconnect' : 'Retry', 'retry');
338
+ error.append(copy, retry);
339
+ panel.append(error);
340
+ }
341
+ else {
342
+ const list = document.createElement('div');
343
+ list.className = 'pb-messages';
344
+ list.setAttribute('aria-live', 'polite');
345
+ list.setAttribute('aria-busy', String(this.loading));
346
+ if (this.nextCursor) {
347
+ const more = button('Load newer comments', 'more');
348
+ more.className = 'pb-more';
349
+ list.append(more);
350
+ }
351
+ if (!this.messages.length) {
352
+ const empty = document.createElement('p');
353
+ empty.className = 'pb-empty';
354
+ empty.textContent = 'No comments yet. Send a message to the business.';
355
+ list.append(empty);
356
+ }
357
+ for (const message of this.messages) {
358
+ const renderMessage = this.currentOptions?.renderMessage;
359
+ if (renderMessage) {
360
+ try {
361
+ const rendered = renderMessage(message);
362
+ if (rendered && rendered.nodeType === 1) {
363
+ rendered.dataset.message ??= message.messageId;
364
+ list.append(rendered);
365
+ continue;
366
+ }
367
+ }
368
+ catch {
369
+ // A consumer renderer must not take down the comments conversation.
370
+ }
371
+ }
372
+ const bubble = document.createElement('article');
373
+ bubble.className = `pb-bubble ${message.participantSide === 'business' ? 'pb-business' : 'pb-customer'}`;
374
+ const who = document.createElement('strong');
375
+ who.textContent = message.participantSide === 'business' ? 'Business' : 'Customer';
376
+ const text = document.createElement('p');
377
+ text.dataset.message = message.messageId;
378
+ text.textContent = message.text;
379
+ const time = document.createElement('time');
380
+ time.dateTime = message.createdAt;
381
+ time.textContent = new Date(message.createdAt).toLocaleString(this.currentOptions?.language);
382
+ bubble.append(who, text, time);
383
+ list.append(bubble);
384
+ }
385
+ panel.append(list);
386
+ const composer = document.createElement('div');
387
+ composer.className = 'pb-composer';
388
+ const textarea = document.createElement('textarea');
389
+ textarea.setAttribute('maxlength', '5000');
390
+ textarea.setAttribute('aria-label', 'Write a message');
391
+ textarea.placeholder = 'Write a message';
392
+ textarea.rows = 2;
393
+ const send = button(this.sending ? 'Sending…' : 'Send', 'send');
394
+ send.className = 'pb-send';
395
+ send.disabled = this.sending || !this.draft.trim();
396
+ composer.append(textarea, send);
397
+ panel.append(composer);
398
+ }
399
+ return panel;
400
+ }
401
+ styles() {
402
+ const style = document.createElement('style');
403
+ style.textContent = `
404
+ :host{display:block;color:var(--pb-comments-text);font:400 14px/1.45 var(--pb-comments-font);--pb-comments-accent:#1b6856;--pb-comments-surface:#fff;--pb-comments-text:#18312b;--pb-comments-muted:#66756f;--pb-comments-border:#dce6e1;--pb-comments-customer:#1b6856;--pb-comments-customer-text:#fff;--pb-comments-business:#eef4f1;--pb-comments-business-text:#18312b;--pb-comments-font:system-ui,sans-serif;--pb-comments-space:16px;--pb-comments-radius:16px}
405
+ *{box-sizing:border-box}button,textarea{font:inherit}button{cursor:pointer}
406
+ .pb-panel{display:flex;flex-direction:column;min-height:360px;max-height:76vh;background:var(--pb-comments-surface);color:var(--pb-comments-text);border:1px solid var(--pb-comments-border);border-radius:var(--pb-comments-radius);overflow:hidden;box-shadow:0 10px 35px #10282018}
407
+ .pb-inline .pb-panel{width:100%}.pb-full-page .pb-panel{height:100%;min-height:70vh;max-height:none}
408
+ header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:var(--pb-comments-space);border-bottom:1px solid var(--pb-comments-border)}
409
+ header strong{font-size:1rem}header div{display:flex;align-items:center;gap:8px}.pb-icon-button{border:0;background:transparent;color:var(--pb-comments-muted);font-size:1.25rem;padding:5px 8px;border-radius:8px}.pb-icon-button:hover{background:var(--pb-comments-business)}
410
+ .pb-unread{color:var(--pb-comments-accent);font-size:.75rem;font-weight:700}
411
+ .pb-messages{display:flex;flex:1;flex-direction:column;gap:10px;overflow:auto;padding:var(--pb-comments-space);min-height:120px}
412
+ .pb-bubble{width:fit-content;max-width:86%;padding:10px 13px;border-radius:14px;overflow-wrap:anywhere}
413
+ .pb-bubble strong{display:block;margin-bottom:4px;font-size:.72rem;opacity:.72}.pb-bubble p{margin:0;white-space:pre-wrap}.pb-bubble time{display:block;margin-top:6px;font-size:.68rem;opacity:.66}
414
+ .pb-business{align-self:flex-start;background:var(--pb-comments-business);color:var(--pb-comments-business-text)}.pb-customer{align-self:flex-end;background:var(--pb-comments-customer);color:var(--pb-comments-customer-text)}
415
+ .pb-composer{display:flex;align-items:flex-end;gap:8px;padding:10px var(--pb-comments-space);border-top:1px solid var(--pb-comments-border)}
416
+ textarea{flex:1;resize:vertical;min-height:44px;max-height:130px;padding:10px 11px;border:1px solid var(--pb-comments-border);border-radius:10px;background:var(--pb-comments-surface);color:var(--pb-comments-text);outline-color:var(--pb-comments-accent)}
417
+ .pb-send,.pb-trigger,.pb-error button{border:0;border-radius:9px;background:var(--pb-comments-accent);color:#fff;font-weight:700;padding:10px 14px}.pb-send:disabled{opacity:.55;cursor:not-allowed}
418
+ .pb-state,.pb-empty,.pb-error{padding:var(--pb-comments-space);color:var(--pb-comments-muted)}.pb-empty{text-align:center;margin:auto}.pb-error{color:var(--pb-comments-text)}.pb-error p{margin:0 0 12px}.pb-error button{padding:8px 12px}
419
+ .pb-more{align-self:center;border:0;background:transparent;color:var(--pb-comments-accent);font-weight:700;padding:6px 10px}
420
+ .pb-overlay{position:fixed;z-index:2147483000;inset:0;display:grid;place-items:center;padding:20px;background:#10201dcc}.pb-modal{width:min(560px,100%)}
421
+ .pb-sheet-overlay{align-items:end;padding:0}.pb-sheet{width:min(720px,100%);max-height:88vh;border-radius:18px 18px 0 0}.pb-trigger{min-height:42px}
422
+ :host([data-dark]){--pb-comments-surface:#17231f;--pb-comments-text:#eef5f1;--pb-comments-muted:#afbbb5;--pb-comments-border:#35453e;--pb-comments-business:#263a32;--pb-comments-business-text:#eef5f1;--pb-comments-customer:#236b58;--pb-comments-customer-text:#fff}
423
+ `;
424
+ return style;
425
+ }
426
+ }
427
+ customElements.define(tagName, PocketBossOrderComments);
428
+ }
429
+ export function mountOrderCommentsElement(element, options) {
430
+ registerOrderCommentsElement();
431
+ const target = element;
432
+ target.options = options;
433
+ return target;
434
+ }
435
+ function button(label, action) {
436
+ const value = document.createElement('button');
437
+ value.type = 'button';
438
+ value.textContent = label;
439
+ value.dataset.action = action;
440
+ return value;
441
+ }
442
+ function contextKey(options) {
443
+ return `${options.apiBaseUrl}\u0000${options.workspaceKey}\u0000${options.orderId}`;
444
+ }
445
+ function isInitiallyOpen(placement) {
446
+ return placement !== 'modal' && placement !== 'bottom-sheet' && placement !== 'headless';
447
+ }
448
+ function secureUuid() {
449
+ if (!globalThis.crypto?.randomUUID)
450
+ throw new OrderCommentsError('Secure randomness is unavailable.');
451
+ return globalThis.crypto.randomUUID();
452
+ }
453
+ function safeColor(value) {
454
+ if (!value || value.length > 64)
455
+ return null;
456
+ return /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\)|hsla?\([0-9., %deg]+\))$/i.test(value) ? value : null;
457
+ }
458
+ function safeFont(value) {
459
+ if (!value || value.length > 120 || !/^[a-z0-9 ,"'-]+$/i.test(value))
460
+ return null;
461
+ return value;
462
+ }
463
+ function safeDimension(value, fallback, min, max) {
464
+ return typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
465
+ }
466
+ function prefersDark() {
467
+ return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
468
+ }
@@ -0,0 +1,57 @@
1
+ export interface OrderCommentsSession {
2
+ sessionId: string;
3
+ token: string;
4
+ expiresAt: string;
5
+ scopes?: readonly string[];
6
+ }
7
+ export interface OrderCommentMessage {
8
+ messageId: string;
9
+ conversationId: string;
10
+ orderId: string;
11
+ participantSide: 'customer' | 'business';
12
+ text: string;
13
+ deliveryState: 'sent';
14
+ createdAt: string;
15
+ }
16
+ export interface OrderCommentPage {
17
+ messages: OrderCommentMessage[];
18
+ nextCursor: string | null;
19
+ unreadCount: number;
20
+ }
21
+ export interface OrderCommentsSessionProvider {
22
+ create(): Promise<OrderCommentsSession>;
23
+ revoke(sessionId: string): Promise<void>;
24
+ }
25
+ export interface OrderCommentsClientOptions {
26
+ apiBaseUrl: string;
27
+ orderId: string;
28
+ sessions: OrderCommentsSessionProvider;
29
+ fetcher?: typeof fetch;
30
+ }
31
+ export declare class OrderCommentsError extends Error {
32
+ readonly status: number | null;
33
+ readonly code: string | null;
34
+ constructor(message: string, status?: number | null, code?: string | null);
35
+ }
36
+ /** REST comments transport, independent of the native Mini App bridge. */
37
+ export declare class OrderCommentsClient {
38
+ private readonly apiBaseUrl;
39
+ private readonly orderId;
40
+ private readonly sessions;
41
+ private readonly fetcher;
42
+ private session;
43
+ private openingSession;
44
+ private closePromise;
45
+ private pendingRevocationId;
46
+ private readonly activeRequests;
47
+ private closed;
48
+ constructor(options: OrderCommentsClientOptions);
49
+ get active(): boolean;
50
+ messages(cursor?: string, limit?: number): Promise<OrderCommentPage>;
51
+ send(text: string, clientMessageId?: string): Promise<OrderCommentMessage>;
52
+ markRead(messageId: string): Promise<void>;
53
+ close(): Promise<void>;
54
+ private request;
55
+ private getSession;
56
+ private revokeSession;
57
+ }
@@ -0,0 +1,226 @@
1
+ export class OrderCommentsError extends Error {
2
+ status;
3
+ code;
4
+ constructor(message, status = null, code = null) {
5
+ super(message);
6
+ this.status = status;
7
+ this.code = code;
8
+ this.name = 'OrderCommentsError';
9
+ }
10
+ }
11
+ /** REST comments transport, independent of the native Mini App bridge. */
12
+ export class OrderCommentsClient {
13
+ apiBaseUrl;
14
+ orderId;
15
+ sessions;
16
+ fetcher;
17
+ session = null;
18
+ openingSession = null;
19
+ closePromise = null;
20
+ pendingRevocationId = null;
21
+ activeRequests = new Set();
22
+ closed = false;
23
+ constructor(options) {
24
+ const base = options.apiBaseUrl.replace(/\/+$/, '');
25
+ if (!/^https?:\/\//i.test(base) || base.length > 2048) {
26
+ throw new OrderCommentsError('A valid PocketBoss API base URL is required.');
27
+ }
28
+ if (!/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(options.orderId)) {
29
+ throw new OrderCommentsError('A valid PocketBoss order ID is required.');
30
+ }
31
+ this.apiBaseUrl = base;
32
+ this.orderId = options.orderId;
33
+ this.sessions = options.sessions;
34
+ this.fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
35
+ }
36
+ get active() {
37
+ return !this.closed;
38
+ }
39
+ async messages(cursor, limit = 50) {
40
+ const query = new URLSearchParams({ limit: String(Math.min(50, Math.max(1, limit))) });
41
+ if (cursor)
42
+ query.set('cursor', cursor);
43
+ const result = await this.request(`/comments/orders/${encodeURIComponent(this.orderId)}/messages?${query}`);
44
+ const data = record(result.data);
45
+ if (!Array.isArray(data.messages) || !(data.nextCursor === null || typeof data.nextCursor === 'string') || typeof data.unreadCount !== 'number') {
46
+ throw new OrderCommentsError('PocketBoss returned an invalid comments page.');
47
+ }
48
+ return {
49
+ messages: data.messages.map(parseMessage),
50
+ nextCursor: data.nextCursor,
51
+ unreadCount: data.unreadCount,
52
+ };
53
+ }
54
+ async send(text, clientMessageId = secureUuid()) {
55
+ const normalizedText = text.trim();
56
+ if (!normalizedText || normalizedText.length > 5000) {
57
+ throw new OrderCommentsError('A message from 1 to 5000 characters is required.', 422, 'VALIDATION_FAILED');
58
+ }
59
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(clientMessageId)) {
60
+ throw new OrderCommentsError('A secure idempotency key is required.', 422, 'VALIDATION_FAILED');
61
+ }
62
+ const result = await this.request(`/comments/orders/${encodeURIComponent(this.orderId)}/messages`, {
63
+ method: 'POST',
64
+ body: JSON.stringify({ client_message_id: clientMessageId, text: normalizedText }),
65
+ });
66
+ return parseMessage(result.data);
67
+ }
68
+ async markRead(messageId) {
69
+ if (!/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(messageId)) {
70
+ throw new OrderCommentsError('A valid comments message ID is required.', 422, 'VALIDATION_FAILED');
71
+ }
72
+ await this.request(`/comments/orders/${encodeURIComponent(this.orderId)}/read`, {
73
+ method: 'POST',
74
+ body: JSON.stringify({ message_id: messageId }),
75
+ });
76
+ }
77
+ async close() {
78
+ if (this.closePromise)
79
+ return this.closePromise;
80
+ this.closed = true;
81
+ const active = this.session;
82
+ this.session = null;
83
+ const opening = this.openingSession;
84
+ for (const controller of this.activeRequests)
85
+ controller.abort();
86
+ this.activeRequests.clear();
87
+ this.closePromise = (async () => {
88
+ if (active)
89
+ await this.revokeSession(active.sessionId);
90
+ if (opening) {
91
+ try {
92
+ await opening;
93
+ }
94
+ catch {
95
+ if (this.pendingRevocationId)
96
+ throw new OrderCommentsError('The comments session could not be revoked during close.', 503, 'COMMENTS_SESSION_CLOSE_FAILED');
97
+ }
98
+ }
99
+ if (this.pendingRevocationId)
100
+ await this.revokeSession(this.pendingRevocationId);
101
+ })();
102
+ return this.closePromise;
103
+ }
104
+ async request(path, init = {}, retried = false) {
105
+ if (this.closed)
106
+ throw new OrderCommentsError('The order comments session is closed.');
107
+ const session = await this.getSession();
108
+ const controller = new AbortController();
109
+ this.activeRequests.add(controller);
110
+ const headers = new Headers(init.headers);
111
+ headers.set('Accept', 'application/json');
112
+ headers.set('Authorization', `Bearer ${session.token}`);
113
+ if (init.body !== undefined)
114
+ headers.set('Content-Type', 'application/json');
115
+ let response;
116
+ try {
117
+ response = await this.fetcher(`${this.apiBaseUrl}${path}`, {
118
+ ...init,
119
+ cache: 'no-store',
120
+ credentials: 'omit',
121
+ redirect: 'error',
122
+ referrerPolicy: 'no-referrer',
123
+ headers,
124
+ signal: controller.signal,
125
+ });
126
+ }
127
+ catch {
128
+ if (this.closed)
129
+ throw new OrderCommentsError('The order comments session is closed.');
130
+ throw new OrderCommentsError('Could not connect to order comments. Check the connection and retry.');
131
+ }
132
+ finally {
133
+ this.activeRequests.delete(controller);
134
+ }
135
+ if ((response.status === 401 || response.status === 410) && !retried) {
136
+ await this.getSession(true);
137
+ return this.request(path, init, true);
138
+ }
139
+ const body = await response.json().catch(() => null);
140
+ if (!response.ok) {
141
+ const error = isRecord(body) && isRecord(body.error) ? body.error : {};
142
+ throw new OrderCommentsError(typeof error.message === 'string' ? error.message : 'Order comments are unavailable. Retry or sign in again.', response.status, typeof error.code === 'string' ? error.code : null);
143
+ }
144
+ if (!isRecord(body) || !isRecord(body.data)) {
145
+ throw new OrderCommentsError('PocketBoss returned an invalid comments response.');
146
+ }
147
+ return body;
148
+ }
149
+ async getSession(force = false) {
150
+ if (this.closed)
151
+ throw new OrderCommentsError('The order comments session is closed.');
152
+ if (!force && this.session && this.session.expiresAtMs > Date.now() + 5_000)
153
+ return this.session;
154
+ if (this.openingSession)
155
+ return this.openingSession;
156
+ this.openingSession = (async () => {
157
+ const previous = this.session;
158
+ this.session = null;
159
+ if (this.pendingRevocationId)
160
+ await this.revokeSession(this.pendingRevocationId);
161
+ if (previous) {
162
+ try {
163
+ await this.revokeSession(previous.sessionId);
164
+ }
165
+ catch {
166
+ throw new OrderCommentsError('The previous comments session could not be closed. Reopen this order after your Mini App session is restored.', 503, 'COMMENTS_SESSION_CLOSE_FAILED');
167
+ }
168
+ }
169
+ let value;
170
+ try {
171
+ value = await this.sessions.create();
172
+ }
173
+ catch {
174
+ throw new OrderCommentsError('Your business session expired. Sign in again to renew comments access.', 401, 'COMMENTS_SESSION_EXPIRED');
175
+ }
176
+ const expiresAtMs = Date.parse(value.expiresAt);
177
+ if (typeof value.sessionId !== 'string' || value.sessionId.length < 16 ||
178
+ typeof value.token !== 'string' || !value.token.startsWith('pbcs_') ||
179
+ !Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
180
+ throw new OrderCommentsError('The Mini App backend returned an invalid comments session.');
181
+ }
182
+ const stored = { ...value, expiresAtMs };
183
+ if (this.closed) {
184
+ await this.revokeSession(stored.sessionId);
185
+ throw new OrderCommentsError('The order comments session is closed.');
186
+ }
187
+ this.session = stored;
188
+ return stored;
189
+ })();
190
+ try {
191
+ return await this.openingSession;
192
+ }
193
+ finally {
194
+ this.openingSession = null;
195
+ }
196
+ }
197
+ async revokeSession(sessionId) {
198
+ this.pendingRevocationId = sessionId;
199
+ await this.sessions.revoke(sessionId);
200
+ if (this.pendingRevocationId === sessionId)
201
+ this.pendingRevocationId = null;
202
+ }
203
+ }
204
+ function parseMessage(value) {
205
+ if (!isRecord(value) || typeof value.messageId !== 'string' ||
206
+ typeof value.conversationId !== 'string' || typeof value.orderId !== 'string' ||
207
+ (value.participantSide !== 'customer' && value.participantSide !== 'business') ||
208
+ typeof value.text !== 'string' || value.deliveryState !== 'sent' ||
209
+ typeof value.createdAt !== 'string' || !Number.isFinite(Date.parse(value.createdAt))) {
210
+ throw new OrderCommentsError('PocketBoss returned an invalid comment.');
211
+ }
212
+ return value;
213
+ }
214
+ function isRecord(value) {
215
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
216
+ }
217
+ function record(value) {
218
+ if (!isRecord(value))
219
+ throw new OrderCommentsError('PocketBoss returned an invalid comments response.');
220
+ return value;
221
+ }
222
+ function secureUuid() {
223
+ if (!globalThis.crypto?.randomUUID)
224
+ throw new OrderCommentsError('Secure randomness is unavailable.');
225
+ return globalThis.crypto.randomUUID();
226
+ }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,8 @@ export { PocketBossClient } from './client.js';
2
2
  export { BridgeError } from './types.js';
3
3
  export { captureDevicePhoto, pickDeviceFiles } from './media.js';
4
4
  export type { CaptureFacingMode, DeviceFilePickerOptions } from './media.js';
5
+ export { OrderCommentsClient, OrderCommentsError } from './comments.js';
6
+ export type { OrderCommentMessage, OrderCommentPage, OrderCommentsClientOptions, OrderCommentsSession, OrderCommentsSessionProvider } from './comments.js';
7
+ export { mountOrderCommentsElement, registerOrderCommentsElement } from './comments-element.js';
8
+ export type { OrderCommentsElementOptions, OrderCommentsPlacement, OrderCommentsTheme, OrderCommentsThemeMode, PocketBossOrderCommentsElement } from './comments-element.js';
5
9
  export type { BridgeCapability, BridgeErrorCode, BridgeEvent, BridgeLimits, BridgeMethod, BridgeMethodMap, BridgeSnapshot, BridgeState, BridgeTransport, MethodRequest, MethodResponse, ReadyMessage, RequestOptions } from './types.js';
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
1
  export { PocketBossClient } from './client.js';
2
2
  export { BridgeError } from './types.js';
3
3
  export { captureDevicePhoto, pickDeviceFiles } from './media.js';
4
+ export { OrderCommentsClient, OrderCommentsError } from './comments.js';
5
+ export { mountOrderCommentsElement, registerOrderCommentsElement } from './comments-element.js';
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@pocketboss/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "exports": {
9
9
  ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
10
+ "./comments": { "types": "./dist/comments/index.d.ts", "default": "./dist/comments/index.js" },
10
11
  "./mock": { "types": "./dist/mock.d.ts", "default": "./dist/mock.js" }
11
12
  },
12
13
  "files": ["dist", "README.md"],