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