@reachbell/sdk 1.0.7

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.mjs ADDED
@@ -0,0 +1,1024 @@
1
+ /**
2
+ * ReachBell official SDK.
3
+ *
4
+ * import { ReachBell } from '@reachbell/sdk';
5
+ * ReachBell.init({ apiKey: 'rb_live_…', apiUrl: 'https://api.reachbell.com' });
6
+ * await ReachBell.prompt(); // shows the configured opt-in prompt
7
+ * const r = await ReachBell.identify('user_42');
8
+ * if (!r.ok) console.warn(r.reason);
9
+ *
10
+ * The SDK auto-loads the service worker (reachbell-sw.js) and bridges
11
+ * configuration from the server (GET /projects/context) — including the
12
+ * dashboard-configured prompt UI.
13
+ *
14
+ * Design notes
15
+ * ─────────────
16
+ * - init() is idempotent. A second call is a no-op unless `force:true`.
17
+ * - Every fetch is wrapped in a 10s AbortController budget so a hung
18
+ * network never deadlocks the bootstrap chain.
19
+ * - prompt() returns a discriminated SubscribeResult — it never throws
20
+ * for any failure mode the API contract acknowledges. SW-register and
21
+ * pushManager.subscribe() rejections are mapped to specific reasons.
22
+ * - Custom prompt UI is built via imperative DOM (element.style.X = v)
23
+ * instead of innerHTML with inline `style="…"` attributes, so it
24
+ * renders cleanly under `Content-Security-Policy: style-src 'self'`
25
+ * without needing 'unsafe-inline'.
26
+ * - The subscription token is mirrored in memory so identify()/tag()
27
+ * keep working through a Safari private-mode storage failure.
28
+ * - The class instance is exposed both as a named export and on
29
+ * `window.ReachBell` so theme + plugin integrations on WordPress
30
+ * end up sharing one client.
31
+ */
32
+ const TOKEN_KEY = 'reachbell_token';
33
+ const NOTIFICATION_HISTORY = 'reachbell_notif_history';
34
+ const DEFAULT_API_URL = 'https://api.reachbell.com';
35
+ const DEFAULT_SW_PATH = '/reachbell-sw.js';
36
+ const DEFAULT_TIMEOUT_MS = 10000;
37
+ /**
38
+ * Brand primary — canonical ReachBell blue (`brand-600`). Source of
39
+ * truth: BRAND.md at the repo root + tailwind config in the marketing
40
+ * site. Do NOT drift toward Tailwind's default indigo `#6366f1` — that
41
+ * shifts purple and clashes with the rest of the surfaces.
42
+ */
43
+ const DEFAULT_ACCENT = '#2563EB';
44
+ const DEFAULT_TITLE = 'Stay in the loop';
45
+ const DEFAULT_ACCEPT = 'Allow';
46
+ const DEFAULT_DECLINE = 'Not now';
47
+ /* ─── Pure helpers — exported for testability ─────────────────── */
48
+ /** Escape the 5 HTML chars that matter inside element bodies + attribute values. */
49
+ function escapeHtml(s) {
50
+ return s.replace(/[&<>"']/g, c => { var _a; return (_a = ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]) !== null && _a !== void 0 ? _a : c; });
51
+ }
52
+ /**
53
+ * Accept only well-formed CSS colour strings before applying them to an
54
+ * `element.style.X` property. Anything else falls back to the default.
55
+ *
56
+ * Server-controlled values (the dashboard's promptConfig.accentColor)
57
+ * route through here so a compromised admin can't inject extra CSS
58
+ * properties / pseudo-element overlays / `javascript:` URLs.
59
+ */
60
+ function safeColor(input, fallback) {
61
+ if (typeof input !== 'string')
62
+ return fallback;
63
+ // hex: #abc, #aabbcc, #aabbccdd (with optional alpha)
64
+ if (/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(input))
65
+ return input;
66
+ // rgb/rgba/hsl/hsla with conservative char set — no quotes, parens, semis, url()
67
+ if (/^(?:rgb|hsl)a?\(\s*[\d.\s,%/-]+\s*\)$/i.test(input))
68
+ return input;
69
+ return fallback;
70
+ }
71
+ /**
72
+ * Decode the URL-safe base64 VAPID public key into a Uint8Array backed by
73
+ * a real ArrayBuffer (not ArrayBufferLike) so it satisfies BufferSource
74
+ * under TS ≥5.7 strict typing.
75
+ */
76
+ function urlBase64ToUint8Array(base64) {
77
+ const padding = '='.repeat((4 - (base64.length % 4)) % 4);
78
+ const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
79
+ const raw = atob(b64);
80
+ const buffer = new ArrayBuffer(raw.length);
81
+ const arr = new Uint8Array(buffer);
82
+ for (let i = 0; i < raw.length; i++)
83
+ arr[i] = raw.charCodeAt(i);
84
+ return arr;
85
+ }
86
+ /* ─── Client ──────────────────────────────────────────────────── */
87
+ class ReachBellClient {
88
+ constructor() {
89
+ this.config = null;
90
+ this.promptConfig = null;
91
+ this.serverContext = null;
92
+ this.swReg = null;
93
+ this.bootstrapping = null;
94
+ this.warned = false;
95
+ /**
96
+ * In-memory mirror of the subscription token. Used as a fallback when
97
+ * localStorage throws (Safari private mode, quota exceeded) so the very
98
+ * next identify()/tag() call doesn't see no_subscription right after a
99
+ * successful subscribe() in the same session.
100
+ */
101
+ this.token = null;
102
+ }
103
+ init(config) {
104
+ if (this.config && !config.force) {
105
+ this.log('init() called twice — ignoring. Pass force:true to override.');
106
+ return;
107
+ }
108
+ this.config = {
109
+ apiUrl: DEFAULT_API_URL,
110
+ swPath: DEFAULT_SW_PATH,
111
+ autoPrompt: false,
112
+ debug: false,
113
+ ...config,
114
+ };
115
+ this.log('Initialized', this.config);
116
+ this.bootstrapping = this.bootstrap();
117
+ void this.bootstrapping;
118
+ }
119
+ /** Resolves once the boot chain (context + SW registration) has settled. */
120
+ get ready() {
121
+ var _a;
122
+ return (_a = this.bootstrapping) !== null && _a !== void 0 ? _a : Promise.reject(new Error('Call ReachBell.init() first'));
123
+ }
124
+ async bootstrap() {
125
+ var _a, _b, _c;
126
+ try {
127
+ const ctx = await this.fetchContext();
128
+ this.serverContext = ctx;
129
+ this.promptConfig = (_a = ctx.promptConfig) !== null && _a !== void 0 ? _a : null;
130
+ await this.registerServiceWorker();
131
+ if (((_b = this.config) === null || _b === void 0 ? void 0 : _b.autoPrompt) && ((_c = this.promptConfig) === null || _c === void 0 ? void 0 : _c.enabled)) {
132
+ // Chrome ≥80 requires a user gesture for Notification.requestPermission().
133
+ // Bootstrap runs in a microtask after page load — no gesture in flight —
134
+ // so we arm a one-shot listener that fires the prompt on first click /
135
+ // key / tap instead of immediately.
136
+ this.armAutoPromptOnGesture();
137
+ }
138
+ }
139
+ catch (e) {
140
+ this.warnOnce('Bootstrap failed — push notifications will not work on this page', e);
141
+ this.log('Bootstrap failed', e);
142
+ }
143
+ }
144
+ armAutoPromptOnGesture() {
145
+ if (typeof document === 'undefined')
146
+ return;
147
+ const events = ['click', 'keydown', 'touchstart'];
148
+ const fire = () => {
149
+ events.forEach(ev => document.removeEventListener(ev, fire));
150
+ void this.prompt();
151
+ };
152
+ events.forEach(ev => document.addEventListener(ev, fire, { once: true, passive: true }));
153
+ }
154
+ /**
155
+ * Shared fetch wrapper. Adds the API key, sets a hard timeout (default
156
+ * 10s), and links any caller-provided AbortSignal so cancellation from
157
+ * either source aborts the fetch.
158
+ */
159
+ async request(path, init = {}, ms = DEFAULT_TIMEOUT_MS) {
160
+ var _a;
161
+ if (!this.config)
162
+ throw new Error('Call ReachBell.init() first');
163
+ const ctrl = new AbortController();
164
+ const outerSignal = init.signal;
165
+ const onOuterAbort = () => ctrl.abort();
166
+ outerSignal === null || outerSignal === void 0 ? void 0 : outerSignal.addEventListener('abort', onOuterAbort, { once: true });
167
+ const timer = setTimeout(() => ctrl.abort(), ms);
168
+ try {
169
+ return await fetch(`${this.config.apiUrl}${path}`, {
170
+ ...init,
171
+ signal: ctrl.signal,
172
+ headers: {
173
+ 'x-api-key': this.config.apiKey,
174
+ ...((_a = init.headers) !== null && _a !== void 0 ? _a : {}),
175
+ },
176
+ });
177
+ }
178
+ finally {
179
+ clearTimeout(timer);
180
+ outerSignal === null || outerSignal === void 0 ? void 0 : outerSignal.removeEventListener('abort', onOuterAbort);
181
+ }
182
+ }
183
+ async fetchContext() {
184
+ const res = await this.request('/projects/context');
185
+ if (!res.ok)
186
+ throw new Error(`Failed to fetch ReachBell context (status ${res.status})`);
187
+ return res.json();
188
+ }
189
+ async registerServiceWorker() {
190
+ if (!('serviceWorker' in navigator)) {
191
+ this.log('Service workers not supported');
192
+ return;
193
+ }
194
+ this.swReg = await navigator.serviceWorker.register(this.config.swPath);
195
+ this.log('SW registered', this.swReg);
196
+ }
197
+ async prompt() {
198
+ var _a, _b;
199
+ if (!this.config)
200
+ return { success: false, reason: 'sdk_not_initialized' };
201
+ // Wait for the in-flight bootstrap so prompt() called immediately after
202
+ // init() doesn't race a missing serverContext / swReg.
203
+ if (this.bootstrapping) {
204
+ try {
205
+ await this.bootstrapping;
206
+ }
207
+ catch ( /* already warned */_c) { /* already warned */ }
208
+ }
209
+ if (!('PushManager' in window))
210
+ return { success: false, reason: 'push_not_supported' };
211
+ if (!this.swReg) {
212
+ try {
213
+ await this.registerServiceWorker();
214
+ }
215
+ catch (e) {
216
+ return { success: false, reason: `sw_register_failed_${(_a = e === null || e === void 0 ? void 0 : e.name) !== null && _a !== void 0 ? _a : 'unknown'}` };
217
+ }
218
+ }
219
+ if (((_b = this.promptConfig) === null || _b === void 0 ? void 0 : _b.enabled) && this.promptConfig.type !== 'native') {
220
+ const accepted = await this.showCustomPrompt(this.promptConfig);
221
+ if (!accepted)
222
+ return { success: false, reason: 'user_declined_soft_prompt' };
223
+ }
224
+ const permission = await Notification.requestPermission();
225
+ if (permission !== 'granted')
226
+ return { success: false, reason: 'permission_denied' };
227
+ return this.subscribe();
228
+ }
229
+ async subscribe() {
230
+ var _a, _b;
231
+ if (!this.swReg || !this.serverContext)
232
+ return { success: false, reason: 'not_ready' };
233
+ let sub;
234
+ try {
235
+ sub = await this.swReg.pushManager.subscribe({
236
+ userVisibleOnly: true,
237
+ applicationServerKey: urlBase64ToUint8Array(this.serverContext.vapidKeys.public),
238
+ });
239
+ }
240
+ catch (e) {
241
+ // NotAllowedError, AbortError, InvalidStateError, InvalidAccessError,
242
+ // and missing/malformed VAPID keys all land here. Map to a specific
243
+ // reason so the integrator can branch on what failed.
244
+ return { success: false, reason: `push_subscribe_failed_${(_a = e === null || e === void 0 ? void 0 : e.name) !== null && _a !== void 0 ? _a : 'unknown'}` };
245
+ }
246
+ try {
247
+ const res = await this.request('/subscribers', {
248
+ method: 'POST',
249
+ headers: { 'Content-Type': 'application/json' },
250
+ // `subscription` is the canonical structured shape. `token` is kept
251
+ // as the legacy double-stringified form so an older API stays
252
+ // compatible during rollout — remove from the wire format in v2.
253
+ body: JSON.stringify({
254
+ subscription: sub.toJSON(),
255
+ token: JSON.stringify(sub),
256
+ tokenType: 'vapid',
257
+ }),
258
+ });
259
+ if (!res.ok)
260
+ return { success: false, reason: `subscribe_failed_${res.status}` };
261
+ const data = await res.json();
262
+ const t = (_b = data.token) !== null && _b !== void 0 ? _b : null;
263
+ this.token = t;
264
+ // Safari private mode + quota-exceeded both throw here. The in-memory
265
+ // mirror above keeps identify()/tag() working for the session anyway.
266
+ try {
267
+ if (t)
268
+ localStorage.setItem(TOKEN_KEY, t);
269
+ }
270
+ catch ( /* fall back to memory */_c) { /* fall back to memory */ }
271
+ return { success: true, token: t !== null && t !== void 0 ? t : undefined };
272
+ }
273
+ catch (e) {
274
+ return { success: false, reason: (e === null || e === void 0 ? void 0 : e.name) === 'AbortError' ? 'timed_out' : 'network_error' };
275
+ }
276
+ }
277
+ async identify(externalId) {
278
+ if (!this.config)
279
+ return { ok: false, reason: 'sdk_not_initialized' };
280
+ const token = this.readToken();
281
+ if (!token)
282
+ return { ok: false, reason: 'no_subscription' };
283
+ return this.postJson('/subscribers/identify', { token, externalId });
284
+ }
285
+ async tag(tags) {
286
+ if (!this.config)
287
+ return { ok: false, reason: 'sdk_not_initialized' };
288
+ const token = this.readToken();
289
+ if (!token)
290
+ return { ok: false, reason: 'no_subscription' };
291
+ return this.postJson('/subscribers/tag', { token, tags });
292
+ }
293
+ async postJson(path, body) {
294
+ try {
295
+ const res = await this.request(path, {
296
+ method: 'POST',
297
+ headers: { 'Content-Type': 'application/json' },
298
+ body: JSON.stringify(body),
299
+ });
300
+ return res.ok ? { ok: true } : { ok: false, reason: `server_${res.status}` };
301
+ }
302
+ catch (e) {
303
+ return { ok: false, reason: (e === null || e === void 0 ? void 0 : e.name) === 'AbortError' ? 'timed_out' : 'network_error' };
304
+ }
305
+ }
306
+ /**
307
+ * Show an in-page toast notification. Returns a function that dismisses
308
+ * the toast early; toasts also auto-dismiss after `duration` ms
309
+ * (default 5000). Multiple toasts stack at the chosen corner.
310
+ *
311
+ * Unlike push notifications, toasts don't require permission — they
312
+ * render entirely inside the page DOM, useful for real-time alerts
313
+ * to users who are already on your site.
314
+ *
315
+ * const dismiss = ReachBell.showToast({
316
+ * title: 'Order shipped 📦',
317
+ * body: 'Order #4821 arrives tomorrow.',
318
+ * url: '/orders/4821',
319
+ * duration: 6000,
320
+ * });
321
+ */
322
+ showToast(opts) {
323
+ var _a, _b, _c, _d, _e, _f;
324
+ if (typeof document === 'undefined')
325
+ return () => { };
326
+ const defaults = (_c = (_b = (_a = this.serverContext) === null || _a === void 0 ? void 0 : _a.customLayouts) === null || _b === void 0 ? void 0 : _b.toast) !== null && _c !== void 0 ? _c : {};
327
+ const layer = this.ensureToastLayer((_e = (_d = opts.position) !== null && _d !== void 0 ? _d : defaults.position) !== null && _e !== void 0 ? _e : 'bottom-right');
328
+ const accent = safeColor((_f = opts.accentColor) !== null && _f !== void 0 ? _f : defaults.accentColor, DEFAULT_ACCENT);
329
+ const duration = typeof opts.duration === 'number'
330
+ ? Math.max(0, opts.duration)
331
+ : (typeof defaults.duration === 'number' ? defaults.duration : 5000);
332
+ const card = document.createElement('div');
333
+ Object.assign(card.style, {
334
+ background: '#0f172a',
335
+ color: '#f8fafc',
336
+ borderRadius: '12px',
337
+ boxShadow: '0 18px 50px rgba(15,23,42,.35)',
338
+ padding: '12px 14px',
339
+ width: '320px',
340
+ maxWidth: 'calc(100vw - 40px)',
341
+ cursor: opts.url ? 'pointer' : 'default',
342
+ display: 'flex',
343
+ gap: '10px',
344
+ alignItems: 'flex-start',
345
+ transition: 'opacity 150ms ease, transform 150ms ease',
346
+ opacity: '0',
347
+ transform: 'translateY(8px)',
348
+ });
349
+ const dot = document.createElement('div');
350
+ Object.assign(dot.style, {
351
+ width: '8px', height: '8px', borderRadius: '999px',
352
+ background: accent, marginTop: '7px', flexShrink: '0',
353
+ });
354
+ card.appendChild(dot);
355
+ const text = document.createElement('div');
356
+ Object.assign(text.style, { flex: '1', minWidth: '0' });
357
+ const titleEl = document.createElement('div');
358
+ Object.assign(titleEl.style, { fontSize: '13.5px', fontWeight: '700', lineHeight: '1.25' });
359
+ titleEl.textContent = opts.title;
360
+ text.appendChild(titleEl);
361
+ if (opts.body) {
362
+ const bodyEl = document.createElement('div');
363
+ Object.assign(bodyEl.style, {
364
+ fontSize: '12px', color: '#cbd5e1', marginTop: '3px', lineHeight: '1.4',
365
+ });
366
+ bodyEl.textContent = opts.body;
367
+ text.appendChild(bodyEl);
368
+ }
369
+ card.appendChild(text);
370
+ const close = document.createElement('button');
371
+ Object.assign(close.style, {
372
+ border: '0', background: 'transparent', color: '#94a3b8',
373
+ padding: '0', cursor: 'pointer', fontSize: '14px', lineHeight: '1',
374
+ marginLeft: '4px', flexShrink: '0',
375
+ });
376
+ close.setAttribute('aria-label', 'Dismiss');
377
+ close.textContent = '×';
378
+ card.appendChild(close);
379
+ let dismissed = false;
380
+ const dismiss = () => {
381
+ if (dismissed)
382
+ return;
383
+ dismissed = true;
384
+ card.style.opacity = '0';
385
+ card.style.transform = 'translateY(8px)';
386
+ setTimeout(() => card.remove(), 200);
387
+ };
388
+ close.addEventListener('click', e => { e.stopPropagation(); dismiss(); });
389
+ if (opts.url) {
390
+ card.addEventListener('click', () => {
391
+ try {
392
+ window.location.href = opts.url;
393
+ }
394
+ catch ( /* navigation blocked */_a) { /* navigation blocked */ }
395
+ dismiss();
396
+ });
397
+ }
398
+ layer.appendChild(card);
399
+ // Animate in on the next frame so the transition fires.
400
+ requestAnimationFrame(() => {
401
+ card.style.opacity = '1';
402
+ card.style.transform = 'translateY(0)';
403
+ });
404
+ if (duration > 0)
405
+ setTimeout(dismiss, duration);
406
+ return dismiss;
407
+ }
408
+ ensureToastLayer(position) {
409
+ const id = `reachbell-toast-layer-${position}`;
410
+ let layer = document.getElementById(id);
411
+ if (layer)
412
+ return layer;
413
+ layer = document.createElement('div');
414
+ layer.id = id;
415
+ Object.assign(layer.style, {
416
+ position: 'fixed',
417
+ zIndex: '2147483646',
418
+ pointerEvents: 'none',
419
+ display: 'flex',
420
+ flexDirection: 'column',
421
+ gap: '10px',
422
+ maxWidth: '360px',
423
+ });
424
+ // Pin to the appropriate corner.
425
+ if (position.startsWith('top'))
426
+ layer.style.top = '20px';
427
+ if (position.startsWith('bottom'))
428
+ layer.style.bottom = '20px';
429
+ if (position.endsWith('right'))
430
+ layer.style.right = '20px';
431
+ if (position.endsWith('left'))
432
+ layer.style.left = '20px';
433
+ // Children inherit pointer-events: auto so cards remain interactive
434
+ // while the layer itself doesn't block clicks elsewhere on the page.
435
+ const style = document.createElement('style');
436
+ style.textContent = `#${id} > * { pointer-events: auto; }`;
437
+ layer.appendChild(style);
438
+ document.body.appendChild(layer);
439
+ return layer;
440
+ }
441
+ /**
442
+ * Apply UTM parameters to every outbound link click on the page,
443
+ * tagging traffic so Google Analytics / etc. attribute the session to
444
+ * ReachBell as the source. Safe to call repeatedly — installs a single
445
+ * delegated click listener regardless of how many times it's invoked.
446
+ *
447
+ * ReachBell.attachUtmTagger({
448
+ * source: 'reachbell',
449
+ * medium: 'push',
450
+ * campaign: 'fall-2026',
451
+ * });
452
+ */
453
+ attachUtmTagger(utm) {
454
+ if (typeof document === 'undefined' || this._utmHandlerInstalled)
455
+ return;
456
+ this._utmHandlerInstalled = true;
457
+ const apply = (target) => {
458
+ if (!target || !target.href)
459
+ return;
460
+ try {
461
+ const u = new URL(target.href, window.location.href);
462
+ // Skip same-origin links — UTM is for outbound attribution only.
463
+ if (u.origin === window.location.origin)
464
+ return;
465
+ if (!u.searchParams.has('utm_source'))
466
+ u.searchParams.set('utm_source', utm.source);
467
+ if (!u.searchParams.has('utm_medium'))
468
+ u.searchParams.set('utm_medium', utm.medium);
469
+ if (utm.campaign && !u.searchParams.has('utm_campaign'))
470
+ u.searchParams.set('utm_campaign', utm.campaign);
471
+ if (utm.term && !u.searchParams.has('utm_term'))
472
+ u.searchParams.set('utm_term', utm.term);
473
+ if (utm.content && !u.searchParams.has('utm_content'))
474
+ u.searchParams.set('utm_content', utm.content);
475
+ target.href = u.toString();
476
+ }
477
+ catch ( /* malformed href — ignore */_a) { /* malformed href — ignore */ }
478
+ };
479
+ // Capture phase so we run before any user click handler that might
480
+ // call preventDefault / change the href.
481
+ document.addEventListener('click', ev => {
482
+ var _a, _b;
483
+ const path = ((_b = (_a = ev.composedPath) === null || _a === void 0 ? void 0 : _a.call(ev)) !== null && _b !== void 0 ? _b : []);
484
+ const anchor = path.find(el => (el === null || el === void 0 ? void 0 : el.tagName) === 'A');
485
+ apply(anchor !== null && anchor !== void 0 ? anchor : null);
486
+ }, true);
487
+ }
488
+ /**
489
+ * Show a sticky banner across the top or bottom of the viewport. Returns
490
+ * a `dismiss()` function. Banners are stacked one-per-position, so a
491
+ * second call replaces the first at the same position.
492
+ *
493
+ * Common uses: site-wide announcements, sale promotions, system alerts
494
+ * that need higher visibility than a corner toast.
495
+ *
496
+ * const dismiss = ReachBell.showBanner({
497
+ * title: '🚀 Black Friday — 30% off everything',
498
+ * body: 'Code BLACK30 — ends midnight',
499
+ * ctaText: 'Shop now',
500
+ * url: '/sale',
501
+ * });
502
+ */
503
+ showBanner(opts) {
504
+ var _a, _b, _c, _d, _e, _f, _g, _h;
505
+ if (typeof document === 'undefined')
506
+ return () => { };
507
+ const defaults = (_c = (_b = (_a = this.serverContext) === null || _a === void 0 ? void 0 : _a.customLayouts) === null || _b === void 0 ? void 0 : _b.banner) !== null && _c !== void 0 ? _c : {};
508
+ const position = (_e = (_d = opts.position) !== null && _d !== void 0 ? _d : defaults.position) !== null && _e !== void 0 ? _e : 'top';
509
+ const dismissible = opts.dismissible !== undefined
510
+ ? opts.dismissible
511
+ : (defaults.dismissible !== undefined ? defaults.dismissible : true);
512
+ const bg = safeColor((_f = opts.backgroundColor) !== null && _f !== void 0 ? _f : defaults.backgroundColor, DEFAULT_ACCENT);
513
+ const text = safeColor((_g = opts.textColor) !== null && _g !== void 0 ? _g : defaults.textColor, '#ffffff');
514
+ // Only one banner per position. Replacing dismisses the previous so
515
+ // the page never accumulates a stack of overlapping bars.
516
+ const layerId = `reachbell-banner-${position}`;
517
+ (_h = document.getElementById(layerId)) === null || _h === void 0 ? void 0 : _h.remove();
518
+ const bar = document.createElement('div');
519
+ bar.id = layerId;
520
+ Object.assign(bar.style, {
521
+ position: 'fixed',
522
+ left: '0', right: '0',
523
+ [position]: '0',
524
+ background: bg,
525
+ color: text,
526
+ padding: '10px 16px',
527
+ zIndex: '2147483645',
528
+ display: 'flex',
529
+ alignItems: 'center',
530
+ justifyContent: 'center',
531
+ gap: '14px',
532
+ fontFamily: 'system-ui, -apple-system, Segoe UI, sans-serif',
533
+ fontSize: '13px',
534
+ lineHeight: '1.35',
535
+ boxShadow: position === 'top'
536
+ ? '0 6px 18px rgba(0,0,0,.12)'
537
+ : '0 -6px 18px rgba(0,0,0,.12)',
538
+ cursor: opts.url ? 'pointer' : 'default',
539
+ transition: 'transform 200ms ease',
540
+ transform: position === 'top' ? 'translateY(-100%)' : 'translateY(100%)',
541
+ });
542
+ const content = document.createElement('div');
543
+ Object.assign(content.style, {
544
+ display: 'flex',
545
+ alignItems: 'baseline',
546
+ gap: '10px',
547
+ flexWrap: 'wrap',
548
+ justifyContent: 'center',
549
+ });
550
+ const titleEl = document.createElement('strong');
551
+ titleEl.style.fontWeight = '700';
552
+ titleEl.textContent = opts.title;
553
+ content.appendChild(titleEl);
554
+ if (opts.body) {
555
+ const bodyEl = document.createElement('span');
556
+ bodyEl.style.opacity = '0.92';
557
+ bodyEl.textContent = opts.body;
558
+ content.appendChild(bodyEl);
559
+ }
560
+ if (opts.ctaText) {
561
+ const cta = document.createElement('span');
562
+ Object.assign(cta.style, {
563
+ marginLeft: '6px',
564
+ textDecoration: 'underline',
565
+ fontWeight: '700',
566
+ cursor: 'pointer',
567
+ });
568
+ cta.textContent = opts.ctaText;
569
+ content.appendChild(cta);
570
+ }
571
+ bar.appendChild(content);
572
+ let dismissed = false;
573
+ const dismiss = () => {
574
+ if (dismissed)
575
+ return;
576
+ dismissed = true;
577
+ bar.style.transform = position === 'top' ? 'translateY(-100%)' : 'translateY(100%)';
578
+ setTimeout(() => bar.remove(), 220);
579
+ };
580
+ if (dismissible) {
581
+ const close = document.createElement('button');
582
+ Object.assign(close.style, {
583
+ background: 'transparent', border: '0', color: text,
584
+ opacity: '0.85', cursor: 'pointer', fontSize: '16px',
585
+ lineHeight: '1', padding: '0 4px', marginLeft: '8px',
586
+ });
587
+ close.setAttribute('aria-label', 'Dismiss banner');
588
+ close.textContent = '×';
589
+ close.addEventListener('click', e => { e.stopPropagation(); dismiss(); });
590
+ bar.appendChild(close);
591
+ }
592
+ if (opts.url) {
593
+ bar.addEventListener('click', () => {
594
+ try {
595
+ window.location.href = opts.url;
596
+ }
597
+ catch ( /* navigation blocked */_a) { /* navigation blocked */ }
598
+ });
599
+ }
600
+ document.body.appendChild(bar);
601
+ requestAnimationFrame(() => { bar.style.transform = 'translateY(0)'; });
602
+ const duration = typeof opts.duration === 'number'
603
+ ? opts.duration
604
+ : (typeof defaults.duration === 'number' ? defaults.duration : 0);
605
+ if (duration > 0)
606
+ setTimeout(dismiss, duration);
607
+ return dismiss;
608
+ }
609
+ /**
610
+ * Record a notification in the local history store. Hosts call this from
611
+ * the service worker (or anywhere they receive a push payload) so the
612
+ * notification center widget can show a recent feed. Bounded to
613
+ * `maxItems` newest, oldest evicted FIFO.
614
+ *
615
+ * Safe to call before init() — items still get persisted.
616
+ */
617
+ recordNotification(item) {
618
+ var _a, _b;
619
+ if (typeof localStorage === 'undefined')
620
+ return;
621
+ try {
622
+ const existing = this.readNotificationHistory();
623
+ const next = {
624
+ id: (_a = item.id) !== null && _a !== void 0 ? _a : `nh_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
625
+ title: item.title,
626
+ body: item.body,
627
+ url: item.url,
628
+ iconUrl: item.iconUrl,
629
+ receivedAt: (_b = item.receivedAt) !== null && _b !== void 0 ? _b : Date.now(),
630
+ read: !!item.read,
631
+ };
632
+ const merged = [next, ...existing].slice(0, 50); // hard cap regardless of viewer
633
+ localStorage.setItem(NOTIFICATION_HISTORY, JSON.stringify(merged));
634
+ // Push a CustomEvent so an open notification center can refresh.
635
+ try {
636
+ window.dispatchEvent(new CustomEvent('reachbell:notification-recorded', { detail: next }));
637
+ }
638
+ catch ( /* CustomEvent unsupported — best-effort */_c) { /* CustomEvent unsupported — best-effort */ }
639
+ }
640
+ catch ( /* JSON or quota — drop silently */_d) { /* JSON or quota — drop silently */ }
641
+ }
642
+ /** Read up to `limit` items, newest first. */
643
+ getNotificationHistory(limit = 20) {
644
+ const all = this.readNotificationHistory();
645
+ return all.slice(0, Math.max(0, limit));
646
+ }
647
+ /** Clear all stored notifications. */
648
+ clearNotificationHistory() {
649
+ if (typeof localStorage === 'undefined')
650
+ return;
651
+ try {
652
+ localStorage.removeItem(NOTIFICATION_HISTORY);
653
+ }
654
+ catch ( /* ignore */_a) { /* ignore */ }
655
+ }
656
+ readNotificationHistory() {
657
+ if (typeof localStorage === 'undefined')
658
+ return [];
659
+ try {
660
+ const raw = localStorage.getItem(NOTIFICATION_HISTORY);
661
+ if (!raw)
662
+ return [];
663
+ const parsed = JSON.parse(raw);
664
+ return Array.isArray(parsed) ? parsed : [];
665
+ }
666
+ catch (_a) {
667
+ return [];
668
+ }
669
+ }
670
+ /**
671
+ * Render a floating bell-icon button anchored in a corner; clicking it
672
+ * opens a dropdown of recent notifications pulled from
673
+ * recordNotification() history. Calling twice replaces the existing
674
+ * widget so callers can re-skin without reloading the page.
675
+ *
676
+ * ReachBell.showNotificationCenter({ position: 'bottom-right' });
677
+ */
678
+ showNotificationCenter(opts = {}) {
679
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
680
+ if (typeof document === 'undefined')
681
+ return () => { };
682
+ const defaults = (_c = (_b = (_a = this.serverContext) === null || _a === void 0 ? void 0 : _a.customLayouts) === null || _b === void 0 ? void 0 : _b.notificationCenter) !== null && _c !== void 0 ? _c : {};
683
+ const position = (_e = (_d = opts.position) !== null && _d !== void 0 ? _d : defaults.position) !== null && _e !== void 0 ? _e : 'bottom-right';
684
+ const accent = safeColor((_f = opts.accentColor) !== null && _f !== void 0 ? _f : defaults.accentColor, DEFAULT_ACCENT);
685
+ const maxItems = (_h = (_g = opts.maxItems) !== null && _g !== void 0 ? _g : defaults.maxItems) !== null && _h !== void 0 ? _h : 20;
686
+ const heading = (_k = (_j = opts.title) !== null && _j !== void 0 ? _j : defaults.title) !== null && _k !== void 0 ? _k : 'Notifications';
687
+ const emptyTx = (_l = opts.emptyText) !== null && _l !== void 0 ? _l : 'No notifications yet.';
688
+ const id = 'reachbell-notification-center';
689
+ (_m = document.getElementById(id)) === null || _m === void 0 ? void 0 : _m.remove();
690
+ const root = document.createElement('div');
691
+ root.id = id;
692
+ Object.assign(root.style, {
693
+ position: 'fixed', zIndex: '2147483645',
694
+ fontFamily: 'system-ui, -apple-system, Segoe UI, sans-serif',
695
+ });
696
+ if (position.startsWith('top'))
697
+ root.style.top = '20px';
698
+ if (position.startsWith('bottom'))
699
+ root.style.bottom = '20px';
700
+ if (position.endsWith('right'))
701
+ root.style.right = '20px';
702
+ if (position.endsWith('left'))
703
+ root.style.left = '20px';
704
+ // Bell button.
705
+ const bell = document.createElement('button');
706
+ Object.assign(bell.style, {
707
+ width: '46px', height: '46px', borderRadius: '999px',
708
+ background: accent, color: '#fff', border: '0',
709
+ cursor: 'pointer', boxShadow: '0 8px 24px rgba(15,23,42,.25)',
710
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
711
+ padding: '0', position: 'relative',
712
+ });
713
+ bell.setAttribute('aria-label', heading);
714
+ // Inline SVG — no external asset, no remote font.
715
+ bell.innerHTML =
716
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
717
+ + '<path d="M12 22a2.5 2.5 0 0 0 2.45-2H9.55A2.5 2.5 0 0 0 12 22Zm6.36-6.36V11a6.36 6.36 0 1 0-12.72 0v4.64L4 17.27v.91h16v-.91l-1.64-1.63Z" fill="currentColor"/>'
718
+ + '</svg>';
719
+ // Unread badge.
720
+ const badge = document.createElement('span');
721
+ Object.assign(badge.style, {
722
+ position: 'absolute', top: '-2px', right: '-2px',
723
+ background: '#ef4444', color: '#fff', borderRadius: '999px',
724
+ minWidth: '18px', height: '18px', padding: '0 5px',
725
+ fontSize: '10px', fontWeight: '800', lineHeight: '18px',
726
+ display: 'none', boxShadow: '0 0 0 2px #fff',
727
+ });
728
+ bell.appendChild(badge);
729
+ // Dropdown panel.
730
+ const panel = document.createElement('div');
731
+ Object.assign(panel.style, {
732
+ position: 'absolute',
733
+ [position.startsWith('top') ? 'top' : 'bottom']: '56px',
734
+ [position.endsWith('right') ? 'right' : 'left']: '0',
735
+ width: '320px',
736
+ maxHeight: '420px',
737
+ overflow: 'hidden',
738
+ display: 'none',
739
+ flexDirection: 'column',
740
+ background: '#fff', color: '#0f172a',
741
+ borderRadius: '14px',
742
+ boxShadow: '0 20px 50px rgba(15,23,42,.22)',
743
+ border: '1px solid rgba(15,23,42,.08)',
744
+ });
745
+ const header = document.createElement('div');
746
+ Object.assign(header.style, {
747
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
748
+ padding: '12px 14px', borderBottom: '1px solid rgba(15,23,42,.08)',
749
+ fontWeight: '700', fontSize: '13.5px',
750
+ });
751
+ const title = document.createElement('span');
752
+ title.textContent = heading;
753
+ const clear = document.createElement('button');
754
+ Object.assign(clear.style, {
755
+ background: 'transparent', border: '0', color: '#64748b',
756
+ cursor: 'pointer', fontSize: '11.5px', fontWeight: '600',
757
+ });
758
+ clear.textContent = 'Clear';
759
+ header.append(title, clear);
760
+ const list = document.createElement('div');
761
+ Object.assign(list.style, { overflowY: 'auto', flex: '1' });
762
+ panel.append(header, list);
763
+ root.append(bell, panel);
764
+ document.body.appendChild(root);
765
+ const render = () => {
766
+ const items = this.getNotificationHistory(maxItems);
767
+ const unread = items.filter(i => !i.read).length;
768
+ badge.style.display = unread > 0 ? 'inline-block' : 'none';
769
+ badge.textContent = unread > 99 ? '99+' : String(unread);
770
+ list.replaceChildren();
771
+ if (items.length === 0) {
772
+ const empty = document.createElement('div');
773
+ Object.assign(empty.style, {
774
+ padding: '28px 16px', textAlign: 'center', color: '#94a3b8',
775
+ fontSize: '12.5px',
776
+ });
777
+ empty.textContent = emptyTx;
778
+ list.appendChild(empty);
779
+ return;
780
+ }
781
+ items.forEach(item => {
782
+ const row = document.createElement('div');
783
+ Object.assign(row.style, {
784
+ padding: '12px 14px', cursor: item.url ? 'pointer' : 'default',
785
+ borderBottom: '1px solid rgba(15,23,42,.05)',
786
+ background: item.read ? 'transparent' : 'rgba(99,102,241,.06)',
787
+ });
788
+ const t = document.createElement('div');
789
+ Object.assign(t.style, { fontSize: '13px', fontWeight: '700' });
790
+ t.textContent = item.title;
791
+ row.appendChild(t);
792
+ if (item.body) {
793
+ const b = document.createElement('div');
794
+ Object.assign(b.style, {
795
+ fontSize: '12px', color: '#475569', marginTop: '2px',
796
+ lineHeight: '1.4',
797
+ });
798
+ b.textContent = item.body;
799
+ row.appendChild(b);
800
+ }
801
+ const time = document.createElement('div');
802
+ Object.assign(time.style, {
803
+ fontSize: '10.5px', color: '#94a3b8', marginTop: '4px',
804
+ fontWeight: '600',
805
+ });
806
+ time.textContent = this.relativeTime(item.receivedAt);
807
+ row.appendChild(time);
808
+ row.addEventListener('click', () => {
809
+ item.read = true;
810
+ this.markRead(item.id);
811
+ render();
812
+ if (item.url) {
813
+ try {
814
+ window.location.href = item.url;
815
+ }
816
+ catch ( /* navigation blocked */_a) { /* navigation blocked */ }
817
+ }
818
+ });
819
+ list.appendChild(row);
820
+ });
821
+ };
822
+ let open = false;
823
+ const toggle = () => {
824
+ open = !open;
825
+ panel.style.display = open ? 'flex' : 'none';
826
+ if (open)
827
+ render();
828
+ };
829
+ bell.addEventListener('click', toggle);
830
+ clear.addEventListener('click', e => {
831
+ e.stopPropagation();
832
+ this.clearNotificationHistory();
833
+ render();
834
+ });
835
+ // Close on outside click.
836
+ const outside = (ev) => {
837
+ if (!open)
838
+ return;
839
+ if (!root.contains(ev.target)) {
840
+ open = false;
841
+ panel.style.display = 'none';
842
+ }
843
+ };
844
+ document.addEventListener('click', outside);
845
+ // Live update when recordNotification() fires while open.
846
+ const liveHandler = () => { if (open)
847
+ render();
848
+ else {
849
+ render(); /* still updates the badge */
850
+ } };
851
+ window.addEventListener('reachbell:notification-recorded', liveHandler);
852
+ // Initial badge render so unread count is visible immediately.
853
+ render();
854
+ return () => {
855
+ document.removeEventListener('click', outside);
856
+ window.removeEventListener('reachbell:notification-recorded', liveHandler);
857
+ root.remove();
858
+ };
859
+ }
860
+ markRead(id) {
861
+ if (typeof localStorage === 'undefined')
862
+ return;
863
+ try {
864
+ const next = this.readNotificationHistory().map(i => i.id === id ? { ...i, read: true } : i);
865
+ localStorage.setItem(NOTIFICATION_HISTORY, JSON.stringify(next));
866
+ }
867
+ catch ( /* ignore */_a) { /* ignore */ }
868
+ }
869
+ relativeTime(epochMs) {
870
+ const diff = Math.max(0, Date.now() - epochMs);
871
+ const s = Math.floor(diff / 1000);
872
+ if (s < 60)
873
+ return 'just now';
874
+ if (s < 3600)
875
+ return `${Math.floor(s / 60)}m ago`;
876
+ if (s < 86400)
877
+ return `${Math.floor(s / 3600)}h ago`;
878
+ if (s < 86400 * 7)
879
+ return `${Math.floor(s / 86400)}d ago`;
880
+ return new Date(epochMs).toLocaleDateString();
881
+ }
882
+ /** Read order: in-memory mirror → localStorage. Survives Safari private mode. */
883
+ readToken() {
884
+ if (this.token)
885
+ return this.token;
886
+ try {
887
+ const t = localStorage.getItem(TOKEN_KEY);
888
+ if (t)
889
+ this.token = t;
890
+ return t;
891
+ }
892
+ catch (_a) {
893
+ return null;
894
+ }
895
+ }
896
+ /**
897
+ * Build the prompt UI imperatively via element.style.* assignments instead
898
+ * of an innerHTML template with inline `style="…"` attributes. JS-set styles
899
+ * are NOT gated by `Content-Security-Policy: style-src` — only
900
+ * parser-processed style attributes are — so this renders cleanly on
901
+ * customer sites with strict CSP.
902
+ */
903
+ async showCustomPrompt(cfg) {
904
+ return new Promise(resolve => {
905
+ var _a, _b, _c, _d, _e;
906
+ const accent = safeColor(cfg.accentColor, DEFAULT_ACCENT);
907
+ const root = document.createElement('div');
908
+ Object.assign(root.style, {
909
+ position: 'fixed',
910
+ zIndex: '2147483647',
911
+ fontFamily: 'system-ui,-apple-system,sans-serif',
912
+ });
913
+ const pos = (_a = cfg.position) !== null && _a !== void 0 ? _a : 'bottom-center';
914
+ if (pos.startsWith('bottom'))
915
+ root.style.bottom = '20px';
916
+ if (pos.startsWith('top'))
917
+ root.style.top = '20px';
918
+ if (pos.endsWith('center')) {
919
+ root.style.left = '50%';
920
+ root.style.transform = 'translateX(-50%)';
921
+ }
922
+ if (pos.endsWith('left'))
923
+ root.style.left = '20px';
924
+ if (pos.endsWith('right'))
925
+ root.style.right = '20px';
926
+ const card = document.createElement('div');
927
+ Object.assign(card.style, {
928
+ background: '#fff',
929
+ borderRadius: '14px',
930
+ boxShadow: '0 18px 50px rgba(15,23,42,.16)',
931
+ padding: '18px',
932
+ width: '320px',
933
+ maxWidth: 'calc(100vw - 40px)',
934
+ });
935
+ root.appendChild(card);
936
+ const row = document.createElement('div');
937
+ Object.assign(row.style, { display: 'flex', gap: '12px', alignItems: 'flex-start' });
938
+ card.appendChild(row);
939
+ const iconBox = document.createElement('div');
940
+ Object.assign(iconBox.style, {
941
+ width: '38px', height: '38px', borderRadius: '10px',
942
+ background: accent,
943
+ display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0',
944
+ });
945
+ // SVG carries no style attributes, so innerHTML here is CSP-safe.
946
+ iconBox.innerHTML =
947
+ '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5">' +
948
+ '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/>' +
949
+ '<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>' +
950
+ '</svg>';
951
+ row.appendChild(iconBox);
952
+ const textCol = document.createElement('div');
953
+ Object.assign(textCol.style, { flex: '1', minWidth: '0' });
954
+ row.appendChild(textCol);
955
+ const titleEl = document.createElement('div');
956
+ Object.assign(titleEl.style, {
957
+ fontWeight: '800', color: '#0f172a', fontSize: '14px', lineHeight: '1.3',
958
+ });
959
+ titleEl.textContent = (_b = cfg.title) !== null && _b !== void 0 ? _b : DEFAULT_TITLE;
960
+ textCol.appendChild(titleEl);
961
+ const bodyEl = document.createElement('div');
962
+ Object.assign(bodyEl.style, {
963
+ color: '#64748b', fontSize: '12px', lineHeight: '1.5', marginTop: '4px',
964
+ });
965
+ bodyEl.textContent = (_c = cfg.body) !== null && _c !== void 0 ? _c : '';
966
+ textCol.appendChild(bodyEl);
967
+ const buttons = document.createElement('div');
968
+ Object.assign(buttons.style, { display: 'flex', gap: '8px', marginTop: '14px' });
969
+ card.appendChild(buttons);
970
+ const acceptBtn = document.createElement('button');
971
+ acceptBtn.dataset.action = 'accept';
972
+ Object.assign(acceptBtn.style, {
973
+ flex: '1', border: '0', padding: '9px 12px', borderRadius: '10px',
974
+ background: accent, color: '#fff', fontWeight: '700', fontSize: '13px', cursor: 'pointer',
975
+ });
976
+ acceptBtn.textContent = (_d = cfg.acceptText) !== null && _d !== void 0 ? _d : DEFAULT_ACCEPT;
977
+ buttons.appendChild(acceptBtn);
978
+ const declineBtn = document.createElement('button');
979
+ declineBtn.dataset.action = 'decline';
980
+ Object.assign(declineBtn.style, {
981
+ flex: '1', border: '0', padding: '9px 12px', borderRadius: '10px',
982
+ background: '#f1f5f9', color: '#475569', fontWeight: '700', fontSize: '13px', cursor: 'pointer',
983
+ });
984
+ declineBtn.textContent = (_e = cfg.declineText) !== null && _e !== void 0 ? _e : DEFAULT_DECLINE;
985
+ buttons.appendChild(declineBtn);
986
+ document.body.appendChild(root);
987
+ root.addEventListener('click', e => {
988
+ var _a;
989
+ const action = (_a = e.target.closest('button')) === null || _a === void 0 ? void 0 : _a.dataset.action;
990
+ if (action !== 'accept' && action !== 'decline')
991
+ return;
992
+ root.remove();
993
+ resolve(action === 'accept');
994
+ });
995
+ });
996
+ }
997
+ warnOnce(message, err) {
998
+ if (this.warned)
999
+ return;
1000
+ this.warned = true;
1001
+ // Surface ONE visible warning in production so integrators have
1002
+ // something to grep when push silently doesn't work.
1003
+ // eslint-disable-next-line no-console
1004
+ console.warn('[ReachBell]', message, err);
1005
+ }
1006
+ log(...args) {
1007
+ var _a;
1008
+ if ((_a = this.config) === null || _a === void 0 ? void 0 : _a.debug) {
1009
+ // eslint-disable-next-line no-console
1010
+ console.log('[ReachBell]', ...args);
1011
+ }
1012
+ }
1013
+ }
1014
+ const ReachBell = new ReachBellClient();
1015
+ if (typeof window !== 'undefined') {
1016
+ // Don't clobber an existing instance — first SDK load wins. Theme +
1017
+ // plugin integrations that both ship the SDK end up sharing one client.
1018
+ if (!window.ReachBell) {
1019
+ window.ReachBell = ReachBell;
1020
+ }
1021
+ }
1022
+
1023
+ export { ReachBell, ReachBell as default, escapeHtml, safeColor, urlBase64ToUint8Array };
1024
+ //# sourceMappingURL=index.mjs.map