@nommos/core 0.0.39 → 0.0.42
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 +7 -1
- package/package.json +1 -1
- package/src/index.d.ts +1 -1
- package/src/lib/consent-manager.d.ts +136 -0
- package/src/lib/consent-manager.js +828 -0
- package/src/lib/consent-manager.js.map +1 -0
- package/src/lib/constants.d.ts +61 -2
- package/src/lib/constants.js +76 -3
- package/src/lib/constants.js.map +1 -1
- package/src/lib/data-attribution.d.ts +1 -0
- package/src/lib/data-attribution.js +101 -32
- package/src/lib/data-attribution.js.map +1 -1
- package/src/lib/dom-safe.d.ts +37 -0
- package/src/lib/dom-safe.js +0 -0
- package/src/lib/dom-safe.js.map +1 -0
- package/src/lib/event-tracker.d.ts +40 -1
- package/src/lib/event-tracker.js +180 -18
- package/src/lib/event-tracker.js.map +1 -1
- package/src/lib/i18n.d.ts +10 -0
- package/src/lib/i18n.js +12 -0
- package/src/lib/i18n.js.map +1 -0
- package/src/lib/popup-inbox.d.ts +143 -0
- package/src/lib/popup-inbox.js +397 -0
- package/src/lib/popup-inbox.js.map +1 -0
- package/src/lib/popup-viewer.d.ts +180 -0
- package/src/lib/popup-viewer.js +920 -0
- package/src/lib/popup-viewer.js.map +1 -0
- package/src/lib/session-tracker.d.ts +2 -1
- package/src/lib/session-tracker.js +19 -4
- package/src/lib/session-tracker.js.map +1 -1
- package/src/lib/types.d.ts +119 -0
- package/src/lib/webSocket-client.d.ts +30 -2
- package/src/lib/webSocket-client.js +126 -7
- package/src/lib/webSocket-client.js.map +1 -1
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
import { POPUP_ANCHOR_SELECTOR, POPUP_LAUNCHER_HIDDEN_KEY, POPUP_MOBILE_BREAKPOINT_PX, POPUP_SCHEMA_VERSION, POPUP_SOUND_KEY, } from './constants';
|
|
2
|
+
import { escapeHtml, sanitizeUrl } from './dom-safe';
|
|
3
|
+
import { resolveLanguage } from './i18n';
|
|
4
|
+
const HOST_ID = 'nommos-popup-host';
|
|
5
|
+
const LAUNCHER_HOST_ID = 'nommos-popup-launcher';
|
|
6
|
+
/** Comfortably past the 260ms slide, so the fallback only fires when `animationend` did not. */
|
|
7
|
+
const GHOST_TTL_MS = 500;
|
|
8
|
+
/** Module-level so the "you have more than one anchor" advice is given once per page, not per render. */
|
|
9
|
+
let warnedMultipleAnchors = false;
|
|
10
|
+
/**
|
|
11
|
+
* Inlined rather than drawn from an icon font: the launcher renders on the *tenant's* origin, where
|
|
12
|
+
* no font of ours is loaded and any external URL is one more thing that can 404 or trip CORS. The
|
|
13
|
+
* shape is Bootstrap Icons' `bell-fill`, matching the icon set used elsewhere in the platform.
|
|
14
|
+
*/
|
|
15
|
+
const BELL_ICON = `
|
|
16
|
+
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false">
|
|
17
|
+
<path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2m.995-14.901a1 1 0 1 0-1.99 0A5 5 0 0 0 3 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901"/>
|
|
18
|
+
</svg>`;
|
|
19
|
+
/**
|
|
20
|
+
* Chrome copy, following the same shape as `CMP_COPY` in the consent manager.
|
|
21
|
+
*
|
|
22
|
+
* <p>Only the frame the SDK draws is translated. The popup body is authored per campaign by the
|
|
23
|
+
* tenant, so translating it here would overwrite their words — a tenant who wants a French popup
|
|
24
|
+
* authors a French popup.
|
|
25
|
+
*
|
|
26
|
+
* <p>There is deliberately no "cancel" label. Closing already has three affordances — the × chip,
|
|
27
|
+
* the backdrop and Escape — and a fourth labelled button sat beside the template's own CTAs as a
|
|
28
|
+
* peer action rather than an escape hatch. `dismiss` is the only chrome action, and it is terminal.
|
|
29
|
+
*/
|
|
30
|
+
const POPUP_COPY = {
|
|
31
|
+
en: {
|
|
32
|
+
close: 'Close',
|
|
33
|
+
dismiss: "Don't show again",
|
|
34
|
+
prev: 'Previous message',
|
|
35
|
+
next: 'Next message',
|
|
36
|
+
dialog: 'Message',
|
|
37
|
+
hideLauncher: 'Hide messages button',
|
|
38
|
+
launcher: 'Open messages',
|
|
39
|
+
launcherUnread: 'Open messages ({count} unread)',
|
|
40
|
+
},
|
|
41
|
+
fr: {
|
|
42
|
+
close: 'Fermer',
|
|
43
|
+
dismiss: 'Ne plus afficher',
|
|
44
|
+
prev: 'Message précédent',
|
|
45
|
+
next: 'Message suivant',
|
|
46
|
+
dialog: 'Message',
|
|
47
|
+
hideLauncher: 'Masquer le bouton des messages',
|
|
48
|
+
launcher: 'Ouvrir les messages',
|
|
49
|
+
launcherUnread: 'Ouvrir les messages ({count} non lus)',
|
|
50
|
+
},
|
|
51
|
+
es: {
|
|
52
|
+
close: 'Cerrar',
|
|
53
|
+
dismiss: 'No volver a mostrar',
|
|
54
|
+
prev: 'Mensaje anterior',
|
|
55
|
+
next: 'Mensaje siguiente',
|
|
56
|
+
dialog: 'Mensaje',
|
|
57
|
+
hideLauncher: 'Ocultar el botón de mensajes',
|
|
58
|
+
launcher: 'Abrir mensajes',
|
|
59
|
+
launcherUnread: 'Abrir mensajes ({count} sin leer)',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
export class PopupViewer {
|
|
63
|
+
constructor(callbacks) {
|
|
64
|
+
this.callbacks = callbacks;
|
|
65
|
+
this.host = null;
|
|
66
|
+
this.root = null;
|
|
67
|
+
this.state = 'hidden';
|
|
68
|
+
this.launcherHost = null;
|
|
69
|
+
this.launcherRoot = null;
|
|
70
|
+
/** The element the launcher is currently mounted into. `null` means the floating fallback. */
|
|
71
|
+
this.anchor = null;
|
|
72
|
+
this.domObserver = null;
|
|
73
|
+
this.remountQueued = false;
|
|
74
|
+
/** Resolved once per viewer: the visitor's language does not change mid-page. */
|
|
75
|
+
this.copy = POPUP_COPY[resolveLanguage()];
|
|
76
|
+
this.items = [];
|
|
77
|
+
/** What is on screen. Only moves once the next popup's content is in hand. */
|
|
78
|
+
this.index = 0;
|
|
79
|
+
/**
|
|
80
|
+
* Where the visitor has navigated to, which runs ahead of {@link index} while a load is in
|
|
81
|
+
* flight. Without it, two quick taps on `›` both step from the same rendered index and the
|
|
82
|
+
* second is swallowed.
|
|
83
|
+
*/
|
|
84
|
+
this.pendingIndex = 0;
|
|
85
|
+
/** Guards against a slow content load landing after a later move and rewinding the carousel. */
|
|
86
|
+
this.swapToken = 0;
|
|
87
|
+
this.lastFocused = null;
|
|
88
|
+
this.touchStartX = null;
|
|
89
|
+
this.audio = null;
|
|
90
|
+
this.onKeyDown = (event) => this.handleKeyDown(event);
|
|
91
|
+
this.onViewportChange = () => this.renderBodyIfVisible();
|
|
92
|
+
// `pagehide` rather than `beforeunload`: it fires on mobile backgrounding and is bfcache-safe,
|
|
93
|
+
// where `beforeunload` is skipped on both.
|
|
94
|
+
this.onPageHide = () => this.handlePageHide();
|
|
95
|
+
}
|
|
96
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
97
|
+
setItems(items) {
|
|
98
|
+
this.items = items;
|
|
99
|
+
this.index = items.length === 0 ? 0 : Math.min(this.index, items.length - 1);
|
|
100
|
+
// A reordered or shortened inbox invalidates any navigation still in flight.
|
|
101
|
+
this.pendingIndex = this.index;
|
|
102
|
+
if (items.length === 0) {
|
|
103
|
+
// Floating, an empty inbox means there is nothing to be, so remove every trace. Anchored is
|
|
104
|
+
// different: the tenant reserved a slot in their own header, and vacating it collapses their
|
|
105
|
+
// layout around the gap. Keep the launcher mounted and let it render its empty state.
|
|
106
|
+
if (this.resolveAnchor() === null) {
|
|
107
|
+
this.teardown();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.state = 'hidden';
|
|
111
|
+
this.root?.getElementById('overlay')?.setAttribute('hidden', '');
|
|
112
|
+
}
|
|
113
|
+
this.ensureHost();
|
|
114
|
+
this.renderLauncher();
|
|
115
|
+
if (this.state === 'visible') {
|
|
116
|
+
void this.renderCurrent();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Removes every trace of the viewer. Called on consent revocation and on an empty inbox. */
|
|
120
|
+
teardown() {
|
|
121
|
+
if (typeof document !== 'undefined') {
|
|
122
|
+
document.removeEventListener('keydown', this.onKeyDown);
|
|
123
|
+
}
|
|
124
|
+
if (typeof window !== 'undefined') {
|
|
125
|
+
window.removeEventListener('resize', this.onViewportChange);
|
|
126
|
+
window.removeEventListener('orientationchange', this.onViewportChange);
|
|
127
|
+
window.removeEventListener('pagehide', this.onPageHide);
|
|
128
|
+
}
|
|
129
|
+
this.domObserver?.disconnect();
|
|
130
|
+
this.domObserver = null;
|
|
131
|
+
this.host?.remove();
|
|
132
|
+
this.launcherHost?.remove();
|
|
133
|
+
this.host = null;
|
|
134
|
+
this.root = null;
|
|
135
|
+
this.launcherHost = null;
|
|
136
|
+
this.launcherRoot = null;
|
|
137
|
+
this.anchor = null;
|
|
138
|
+
this.state = 'hidden';
|
|
139
|
+
this.index = 0;
|
|
140
|
+
this.pendingIndex = 0;
|
|
141
|
+
this.items = [];
|
|
142
|
+
}
|
|
143
|
+
isVisible() {
|
|
144
|
+
return this.state === 'visible' || this.state === 'opening';
|
|
145
|
+
}
|
|
146
|
+
async open(id, trigger) {
|
|
147
|
+
if (this.items.length === 0 || this.state === 'opening' || this.state === 'closing')
|
|
148
|
+
return;
|
|
149
|
+
const target = id === undefined ? 0 : this.items.findIndex((item) => item.id === id);
|
|
150
|
+
if (target < 0)
|
|
151
|
+
return;
|
|
152
|
+
this.index = target;
|
|
153
|
+
this.pendingIndex = target;
|
|
154
|
+
this.ensureHost();
|
|
155
|
+
this.state = 'opening';
|
|
156
|
+
this.lastFocused = typeof document !== 'undefined' ? document.activeElement : null;
|
|
157
|
+
// A failed render must not report an open. `close()` cannot undo it either — it early-returns
|
|
158
|
+
// while the state is still 'opening' — so the abort is handled here, where the state is owned.
|
|
159
|
+
if (!(await this.renderCurrent())) {
|
|
160
|
+
this.root?.getElementById('overlay')?.setAttribute('hidden', '');
|
|
161
|
+
this.state = 'hidden';
|
|
162
|
+
this.renderLauncher();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.state = 'visible';
|
|
166
|
+
this.focusDialog();
|
|
167
|
+
this.callbacks.onOpened(this.items[this.index], trigger);
|
|
168
|
+
}
|
|
169
|
+
close() {
|
|
170
|
+
if (this.state !== 'visible')
|
|
171
|
+
return;
|
|
172
|
+
const item = this.items[this.index];
|
|
173
|
+
this.state = 'closing';
|
|
174
|
+
const overlay = this.root?.getElementById('overlay');
|
|
175
|
+
if (overlay)
|
|
176
|
+
overlay.setAttribute('hidden', '');
|
|
177
|
+
this.state = 'hidden';
|
|
178
|
+
this.renderLauncher();
|
|
179
|
+
if (this.lastFocused instanceof HTMLElement) {
|
|
180
|
+
this.lastFocused.focus();
|
|
181
|
+
}
|
|
182
|
+
// Closing is still not dismissing — the entry stays in the inbox and is never reported as
|
|
183
|
+
// dismissed — but the launcher only survives while something else is unread. See
|
|
184
|
+
// renderLauncher.
|
|
185
|
+
if (item)
|
|
186
|
+
this.callbacks.onClosed(item);
|
|
187
|
+
}
|
|
188
|
+
// ── Rendering ──────────────────────────────────────────────────────────────
|
|
189
|
+
ensureHost() {
|
|
190
|
+
if (this.host || typeof document === 'undefined')
|
|
191
|
+
return;
|
|
192
|
+
this.host = document.createElement('div');
|
|
193
|
+
this.host.id = HOST_ID;
|
|
194
|
+
// `all: initial` blocks inherited properties from the host page; without it a tenant's
|
|
195
|
+
// `body { font-size: 62.5% }` silently halves every size inside the popup.
|
|
196
|
+
this.host.setAttribute('style', 'all: initial;');
|
|
197
|
+
document.body.appendChild(this.host);
|
|
198
|
+
this.root = this.host.attachShadow({ mode: 'open' });
|
|
199
|
+
this.root.innerHTML = `<style>${CHROME_CSS}</style><div id="overlay" hidden></div>`;
|
|
200
|
+
this.root.addEventListener('click', (event) => this.handleClick(event));
|
|
201
|
+
this.root.addEventListener('touchstart', (event) => this.handleTouchStart(event), { passive: true });
|
|
202
|
+
this.root.addEventListener('touchend', (event) => this.handleTouchEnd(event), { passive: true });
|
|
203
|
+
document.addEventListener('keydown', this.onKeyDown);
|
|
204
|
+
window.addEventListener('resize', this.onViewportChange);
|
|
205
|
+
window.addEventListener('orientationchange', this.onViewportChange);
|
|
206
|
+
window.addEventListener('pagehide', this.onPageHide);
|
|
207
|
+
}
|
|
208
|
+
handlePageHide() {
|
|
209
|
+
if (this.state !== 'visible')
|
|
210
|
+
return;
|
|
211
|
+
const item = this.items[this.index];
|
|
212
|
+
if (item)
|
|
213
|
+
this.callbacks.onAbandoned(item);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The launcher gets a host of its own rather than sharing the modal's.
|
|
217
|
+
*
|
|
218
|
+
* <p>It has to, because the two have opposite placement needs. The modal is `position: fixed` and
|
|
219
|
+
* must resolve against the viewport, but `fixed` resolves against the nearest ancestor carrying a
|
|
220
|
+
* `transform`, `filter` or `contain` — which sticky headers routinely do. Mounting one shared host
|
|
221
|
+
* inside a tenant's header would therefore drag the modal in with it and clip it to the header.
|
|
222
|
+
*/
|
|
223
|
+
ensureLauncherHost() {
|
|
224
|
+
if (this.launcherHost || typeof document === 'undefined')
|
|
225
|
+
return;
|
|
226
|
+
this.launcherHost = document.createElement('div');
|
|
227
|
+
this.launcherHost.id = LAUNCHER_HOST_ID;
|
|
228
|
+
this.launcherRoot = this.launcherHost.attachShadow({ mode: 'open' });
|
|
229
|
+
this.launcherRoot.innerHTML = `<style>${LAUNCHER_CSS}</style><div id="launcher-slot"></div>`;
|
|
230
|
+
// `handleClick` dispatches on ids and `data-action` and does not care which root the event
|
|
231
|
+
// came from, so the second root reuses it verbatim.
|
|
232
|
+
this.launcherRoot.addEventListener('click', (event) => this.handleClick(event));
|
|
233
|
+
this.observeDom();
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* @returns the integrator's chosen mount point, or null for the floating fallback.
|
|
237
|
+
*/
|
|
238
|
+
resolveAnchor() {
|
|
239
|
+
if (typeof document === 'undefined')
|
|
240
|
+
return null;
|
|
241
|
+
const matches = document.querySelectorAll(POPUP_ANCHOR_SELECTOR);
|
|
242
|
+
if (matches.length > 1 && !warnedMultipleAnchors) {
|
|
243
|
+
warnedMultipleAnchors = true;
|
|
244
|
+
// The one place this file deliberately writes to the tenant's console: unlike the autoplay
|
|
245
|
+
// and storage failures silenced elsewhere, this is an integration mistake only they can fix.
|
|
246
|
+
console.warn(`[nommos] Multiple ${POPUP_ANCHOR_SELECTOR} elements found. Using the first match.`);
|
|
247
|
+
}
|
|
248
|
+
return matches[0] ?? null;
|
|
249
|
+
}
|
|
250
|
+
/** Places the launcher host under the current anchor, or under `body` when there is none. */
|
|
251
|
+
mountLauncher() {
|
|
252
|
+
if (!this.launcherHost || typeof document === 'undefined')
|
|
253
|
+
return;
|
|
254
|
+
const anchor = this.resolveAnchor();
|
|
255
|
+
const parent = anchor ?? document.body;
|
|
256
|
+
if (this.anchor === anchor && this.launcherHost.parentNode === parent)
|
|
257
|
+
return;
|
|
258
|
+
this.anchor = anchor;
|
|
259
|
+
if (anchor)
|
|
260
|
+
this.launcherHost.setAttribute('data-anchored', '');
|
|
261
|
+
else
|
|
262
|
+
this.launcherHost.removeAttribute('data-anchored');
|
|
263
|
+
parent.appendChild(this.launcherHost); // appendChild moves it; no explicit remove needed.
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Follows the anchor for the life of the page. A one-shot query at init would almost always miss
|
|
267
|
+
* it: the SDK boots from a script tag, while an Angular or React tenant renders its header
|
|
268
|
+
* afterwards. This also covers the anchor being removed or swapped on an SPA route change.
|
|
269
|
+
*
|
|
270
|
+
* <p>The callback fires on every DOM change the tenant's app makes, so it must stay cheap — it
|
|
271
|
+
* only re-resolves and compares, and a burst of mutations collapses into one remount per frame.
|
|
272
|
+
*/
|
|
273
|
+
observeDom() {
|
|
274
|
+
if (this.domObserver || typeof MutationObserver === 'undefined')
|
|
275
|
+
return;
|
|
276
|
+
this.domObserver = new MutationObserver(() => {
|
|
277
|
+
if (this.remountQueued)
|
|
278
|
+
return;
|
|
279
|
+
this.remountQueued = true;
|
|
280
|
+
const run = () => {
|
|
281
|
+
this.remountQueued = false;
|
|
282
|
+
if (!this.launcherHost)
|
|
283
|
+
return;
|
|
284
|
+
if (this.resolveAnchor() !== this.anchor || !this.launcherHost.isConnected) {
|
|
285
|
+
// Re-render rather than just re-parent: the markup differs between the two modes, so a
|
|
286
|
+
// move alone would leave an anchored launcher wearing the floating hide chip.
|
|
287
|
+
this.renderLauncher();
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
if (typeof requestAnimationFrame === 'function')
|
|
291
|
+
requestAnimationFrame(run);
|
|
292
|
+
else
|
|
293
|
+
setTimeout(run, 0);
|
|
294
|
+
});
|
|
295
|
+
this.domObserver.observe(document.body, { childList: true, subtree: true });
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Whether the launcher shows at all depends on where it lives — the two placements have opposite
|
|
299
|
+
* defaults, and that is deliberate.
|
|
300
|
+
*
|
|
301
|
+
* <p><b>Floating</b> shows only while something is unread. A permanent button parked over
|
|
302
|
+
* someone else's page with nothing behind it is clutter, so once the visitor has seen everything
|
|
303
|
+
* it disappears. The cost is real: a read-but-undismissed popup then has no way back for the rest
|
|
304
|
+
* of the session, short of the host app calling {@link PopupInbox.openViewer}.
|
|
305
|
+
*
|
|
306
|
+
* <p><b>Anchored</b> always shows. The tenant deliberately reserved a slot in their own header,
|
|
307
|
+
* so vacating it leaves a hole their layout collapses around — a flex or grid {@code gap} still
|
|
308
|
+
* applies to a zero-width item — and a header bell that vanishes reads as broken chrome rather
|
|
309
|
+
* than as tidiness. Persisting also restores the way back, so a popup closed by accident can be
|
|
310
|
+
* reopened.
|
|
311
|
+
*/
|
|
312
|
+
renderLauncher() {
|
|
313
|
+
this.ensureLauncherHost();
|
|
314
|
+
this.mountLauncher();
|
|
315
|
+
const slot = this.launcherRoot?.getElementById('launcher-slot');
|
|
316
|
+
if (!slot)
|
|
317
|
+
return;
|
|
318
|
+
const unread = this.items.filter((item) => item.status !== 'VIEWED').length;
|
|
319
|
+
const anchored = this.anchor !== null;
|
|
320
|
+
if (!anchored && (unread === 0 || this.launcherHidden())) {
|
|
321
|
+
slot.innerHTML = '';
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// No hide chip when anchored: taking the icon out of the middle of the tenant's own header
|
|
325
|
+
// leaves a hole in their layout, and the corner-clutter it exists to solve isn't there.
|
|
326
|
+
const hide = anchored
|
|
327
|
+
? ''
|
|
328
|
+
: `<button id="launcher-hide" data-action="hide-launcher" aria-label="${escapeHtml(this.copy.hideLauncher)}">×</button>`;
|
|
329
|
+
// With nothing unread there is nothing to draw the eye to, so the badge and the wiggle both
|
|
330
|
+
// drop away and the bell sits quietly among the tenant's own header icons.
|
|
331
|
+
const badge = unread > 0 ? `<span id="badge">${escapeHtml(String(unread))}</span>` : '';
|
|
332
|
+
const attention = unread > 0 ? ' class="np-attention"' : '';
|
|
333
|
+
const label = unread > 0
|
|
334
|
+
? this.copy.launcherUnread.replace('{count}', String(unread))
|
|
335
|
+
: this.copy.launcher;
|
|
336
|
+
slot.innerHTML = `
|
|
337
|
+
<div id="launcher-wrap" part="launcher-wrap">
|
|
338
|
+
<button id="launcher" part="launcher"${attention} aria-label="${escapeHtml(label)}">
|
|
339
|
+
${BELL_ICON}
|
|
340
|
+
${badge}
|
|
341
|
+
</button>
|
|
342
|
+
${hide}
|
|
343
|
+
</div>`;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Hiding is per page session and deliberately weaker than dismissing: it silences the launcher
|
|
347
|
+
* for a visitor who does not want it in the corner right now, but a genuinely new popup clears
|
|
348
|
+
* it again (see {@link notifyArrival}) because that is a new reason to be shown.
|
|
349
|
+
*/
|
|
350
|
+
launcherHidden() {
|
|
351
|
+
try {
|
|
352
|
+
return sessionStorage.getItem(POPUP_LAUNCHER_HIDDEN_KEY) === '1';
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
setLauncherHidden(hidden) {
|
|
359
|
+
try {
|
|
360
|
+
if (hidden)
|
|
361
|
+
sessionStorage.setItem(POPUP_LAUNCHER_HIDDEN_KEY, '1');
|
|
362
|
+
else
|
|
363
|
+
sessionStorage.removeItem(POPUP_LAUNCHER_HIDDEN_KEY);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
/* storage blocked — the launcher simply stays visible */
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* A popup arrived while the visitor was already on the page. Without this the only signal is a
|
|
371
|
+
* small badge appearing in a corner nobody is looking at, so shake the launcher and play a short
|
|
372
|
+
* chime. Callers suppress it for first load, cache restore and the auto-open path — see
|
|
373
|
+
* `PopupInbox.refresh`.
|
|
374
|
+
*/
|
|
375
|
+
notifyArrival() {
|
|
376
|
+
this.setLauncherHidden(false);
|
|
377
|
+
this.renderLauncher();
|
|
378
|
+
const launcher = this.launcherRoot?.getElementById('launcher');
|
|
379
|
+
if (launcher) {
|
|
380
|
+
launcher.classList.add('np-shake');
|
|
381
|
+
launcher.addEventListener('animationend', () => launcher.classList.remove('np-shake'), {
|
|
382
|
+
once: true,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
this.launcherRoot?.getElementById('badge')?.classList.add('np-pulse');
|
|
386
|
+
this.chime();
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Synthesised rather than loaded from a file: a popup renders on the *tenant's* origin, so any
|
|
390
|
+
* asset URL is one more thing that can 404 or trip CORS on someone else's domain.
|
|
391
|
+
*
|
|
392
|
+
* <p>Autoplay policy blocks audio until the page has had a user gesture. If the context is
|
|
393
|
+
* suspended we skip silently instead of calling `resume()` — the shake already carries the
|
|
394
|
+
* signal, and a warning in the tenant's console would be noise they cannot act on.
|
|
395
|
+
*/
|
|
396
|
+
chime() {
|
|
397
|
+
if (!PopupViewer.soundEnabled())
|
|
398
|
+
return;
|
|
399
|
+
try {
|
|
400
|
+
const Ctor = window
|
|
401
|
+
.AudioContext ??
|
|
402
|
+
window.webkitAudioContext;
|
|
403
|
+
if (!Ctor)
|
|
404
|
+
return;
|
|
405
|
+
this.audio ?? (this.audio = new Ctor());
|
|
406
|
+
const ctx = this.audio;
|
|
407
|
+
if (ctx.state === 'suspended')
|
|
408
|
+
return;
|
|
409
|
+
const gain = ctx.createGain();
|
|
410
|
+
gain.connect(ctx.destination);
|
|
411
|
+
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
|
412
|
+
gain.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + 0.02);
|
|
413
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.28);
|
|
414
|
+
[880, 1320].forEach((frequency, i) => {
|
|
415
|
+
const osc = ctx.createOscillator();
|
|
416
|
+
osc.type = 'sine';
|
|
417
|
+
osc.frequency.value = frequency;
|
|
418
|
+
osc.connect(gain);
|
|
419
|
+
osc.start(ctx.currentTime + i * 0.09);
|
|
420
|
+
osc.stop(ctx.currentTime + 0.3);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
/* no WebAudio (jsdom, locked-down browser) — the shake is the fallback */
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
static soundEnabled() {
|
|
428
|
+
try {
|
|
429
|
+
return localStorage.getItem(POPUP_SOUND_KEY) !== '0';
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
static setSoundEnabled(enabled) {
|
|
436
|
+
try {
|
|
437
|
+
localStorage.setItem(POPUP_SOUND_KEY, enabled ? '1' : '0');
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
/* storage blocked — the preference just does not persist */
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** @returns false when there is nothing renderable, so the caller can abort the open. */
|
|
444
|
+
async renderCurrent() {
|
|
445
|
+
const item = this.items[this.index];
|
|
446
|
+
const overlay = this.root?.getElementById('overlay');
|
|
447
|
+
if (!item || !overlay)
|
|
448
|
+
return false;
|
|
449
|
+
overlay.removeAttribute('hidden');
|
|
450
|
+
overlay.innerHTML = this.shell(item, '<div class="np-surface np-loading">…</div>');
|
|
451
|
+
const content = await this.callbacks.loadContent(item.id);
|
|
452
|
+
if (!content) {
|
|
453
|
+
// Expired or dismissed elsewhere between the inbox read and the open.
|
|
454
|
+
this.callbacks.onRenderFailed(item, 'content_unavailable');
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
overlay.innerHTML = this.shell(item, this.bodyFor(item, content), content);
|
|
458
|
+
this.applyVariantCss(content);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
renderBodyIfVisible() {
|
|
462
|
+
if (this.state === 'visible')
|
|
463
|
+
void this.renderCurrent();
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Picks the variant by viewport width, falling back to whichever one exists — a template with
|
|
467
|
+
* only a desktop variant should still show on a phone rather than showing nothing.
|
|
468
|
+
*/
|
|
469
|
+
bodyFor(item, content) {
|
|
470
|
+
const version = content.schemaVersion ?? POPUP_SCHEMA_VERSION;
|
|
471
|
+
if (version !== POPUP_SCHEMA_VERSION) {
|
|
472
|
+
// Degrade rather than misrender: the title and CTAs are shape we still understand.
|
|
473
|
+
this.callbacks.onRenderFailed(item, `unsupported_schema_${version}`);
|
|
474
|
+
return `<div class="np-surface np-fallback">${escapeHtml(content.title ?? item.title ?? '')}</div>`;
|
|
475
|
+
}
|
|
476
|
+
const mobile = this.isMobileViewport();
|
|
477
|
+
const chosen = mobile
|
|
478
|
+
? content.mobileHtml || content.desktopHtml
|
|
479
|
+
: content.desktopHtml || content.mobileHtml;
|
|
480
|
+
return `<div class="np-body">${chosen ?? ''}</div>`;
|
|
481
|
+
}
|
|
482
|
+
applyVariantCss(content) {
|
|
483
|
+
const styleEl = this.root?.getElementById('variant-css');
|
|
484
|
+
if (!styleEl)
|
|
485
|
+
return;
|
|
486
|
+
const mobile = this.isMobileViewport();
|
|
487
|
+
styleEl.textContent = (mobile
|
|
488
|
+
? content.mobileCss || content.desktopCss
|
|
489
|
+
: content.desktopCss || content.mobileCss) ?? '';
|
|
490
|
+
}
|
|
491
|
+
shell(item, body, content) {
|
|
492
|
+
const title = escapeHtml(content?.title ?? item.title ?? '');
|
|
493
|
+
const buttons = content?.buttons ?? item.buttons ?? [];
|
|
494
|
+
const many = this.items.length > 1;
|
|
495
|
+
const c = this.copy;
|
|
496
|
+
return `
|
|
497
|
+
<style id="variant-css"></style>
|
|
498
|
+
<div class="np-backdrop" data-action="close"></div>
|
|
499
|
+
<div class="np-modal" role="dialog" aria-modal="true" aria-label="${title || escapeHtml(c.dialog)}" tabindex="-1">
|
|
500
|
+
<button class="np-chip np-close" data-action="close" aria-label="${escapeHtml(c.close)}">×</button>
|
|
501
|
+
${many ? `<button class="np-chip np-nav np-prev" data-action="prev" aria-label="${escapeHtml(c.prev)}">‹</button>` : ''}
|
|
502
|
+
<div class="np-stage">${body}</div>
|
|
503
|
+
${many ? `<button class="np-chip np-nav np-next" data-action="next" aria-label="${escapeHtml(c.next)}">›</button>` : ''}
|
|
504
|
+
<div class="np-underbar">
|
|
505
|
+
${many ? `<div class="np-dots">${this.dotsMarkup()}</div>` : ''}
|
|
506
|
+
${this.ctaMarkup(buttons)}
|
|
507
|
+
<button class="np-dismiss" data-action="dismiss">${escapeHtml(c.dismiss)}</button>
|
|
508
|
+
</div>
|
|
509
|
+
</div>`;
|
|
510
|
+
}
|
|
511
|
+
ctaMarkup(buttons) {
|
|
512
|
+
return buttons
|
|
513
|
+
.map((button, i) => {
|
|
514
|
+
const url = sanitizeUrl(button.url);
|
|
515
|
+
if (!url)
|
|
516
|
+
return '';
|
|
517
|
+
return `<a class="np-cta" data-action="cta" data-index="${i}" href="${escapeHtml(url)}"
|
|
518
|
+
target="_blank" rel="noopener noreferrer">${escapeHtml(button.label)}</a>`;
|
|
519
|
+
})
|
|
520
|
+
.join('');
|
|
521
|
+
}
|
|
522
|
+
dotsMarkup() {
|
|
523
|
+
return this.items
|
|
524
|
+
.map((_, i) => `<span class="np-dot${i === this.index ? ' np-dot-on' : ''}"></span>`)
|
|
525
|
+
.join('');
|
|
526
|
+
}
|
|
527
|
+
isMobileViewport() {
|
|
528
|
+
return typeof window !== 'undefined' && window.innerWidth < POPUP_MOBILE_BREAKPOINT_PX;
|
|
529
|
+
}
|
|
530
|
+
// ── Interaction ────────────────────────────────────────────────────────────
|
|
531
|
+
/**
|
|
532
|
+
* One delegated listener covers the chrome and the authored body alike, which is what lets an
|
|
533
|
+
* ordinary `<a href>` inside a template be attributed without any script in the popup.
|
|
534
|
+
*/
|
|
535
|
+
handleClick(event) {
|
|
536
|
+
const path = event.composedPath();
|
|
537
|
+
const item = this.items[this.index];
|
|
538
|
+
for (const node of path) {
|
|
539
|
+
if (!(node instanceof HTMLElement))
|
|
540
|
+
continue;
|
|
541
|
+
if (node.id === 'launcher') {
|
|
542
|
+
void this.open(undefined, 'manual');
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const action = node.dataset['action'];
|
|
546
|
+
if (action === 'hide-launcher') {
|
|
547
|
+
this.setLauncherHidden(true);
|
|
548
|
+
this.renderLauncher();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (action === 'close') {
|
|
552
|
+
this.close();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (action === 'dismiss') {
|
|
556
|
+
if (item)
|
|
557
|
+
this.callbacks.onDismissed(item);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (action === 'next' || action === 'prev') {
|
|
561
|
+
this.move(action);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (action === 'cta' && item) {
|
|
565
|
+
const index = Number(node.dataset['index'] ?? 0);
|
|
566
|
+
this.callbacks.onCtaClick(item, index, node.getAttribute('href') ?? '');
|
|
567
|
+
this.closeAfterFollowing();
|
|
568
|
+
return; // Navigation proceeds; the event is not cancelled.
|
|
569
|
+
}
|
|
570
|
+
if (node.tagName === 'A' && item) {
|
|
571
|
+
this.callbacks.onLinkClick(item, node.getAttribute('href') ?? '');
|
|
572
|
+
this.closeAfterFollowing();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Closes the viewer after a link has been followed, without dismissing.
|
|
579
|
+
*
|
|
580
|
+
* <p>Closing rather than dismissing is deliberate and matches the backend, where
|
|
581
|
+
* {@code recordCtaClick} only marks the popup viewed: a visitor who clicks through has engaged,
|
|
582
|
+
* not refused, and can reopen the message from the launcher.
|
|
583
|
+
*
|
|
584
|
+
* <p>{@link close} hides the overlay rather than detaching it, which is what makes this safe to
|
|
585
|
+
* call from inside the click. Removing the anchor from the document mid-dispatch can cancel the
|
|
586
|
+
* navigation it was about to perform; hiding an ancestor cannot.
|
|
587
|
+
*/
|
|
588
|
+
closeAfterFollowing() {
|
|
589
|
+
this.close();
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Moves the carousel by one, keeping the current popup on screen until the next one is ready.
|
|
593
|
+
*
|
|
594
|
+
* <p>This used to re-render the whole modal through {@link renderCurrent}, which tore the shell
|
|
595
|
+
* down to a loading placeholder before awaiting the content. Content is cached after its first
|
|
596
|
+
* fetch, so the await almost always resolved on the next microtask — but "almost always" still
|
|
597
|
+
* paints one frame of collapsed spinner, which is what read as a flicker. Loading first and
|
|
598
|
+
* swapping second means the visitor never sees an intermediate state.
|
|
599
|
+
*/
|
|
600
|
+
move(direction) {
|
|
601
|
+
if (this.items.length < 2 || this.state !== 'visible')
|
|
602
|
+
return;
|
|
603
|
+
const from = this.items[this.index];
|
|
604
|
+
const delta = direction === 'next' ? 1 : -1;
|
|
605
|
+
// Stepped from the pending position, not the rendered one, so a burst of taps accumulates.
|
|
606
|
+
const nextIndex = (this.pendingIndex + delta + this.items.length) % this.items.length;
|
|
607
|
+
const next = this.items[nextIndex];
|
|
608
|
+
if (!next)
|
|
609
|
+
return;
|
|
610
|
+
this.pendingIndex = nextIndex;
|
|
611
|
+
this.callbacks.onCarouselMove(from, direction);
|
|
612
|
+
// Spamming the arrows must not let a slow load land after a later one and rewind the carousel.
|
|
613
|
+
const token = ++this.swapToken;
|
|
614
|
+
void this.callbacks.loadContent(next.id).then((content) => {
|
|
615
|
+
if (token !== this.swapToken || this.state !== 'visible')
|
|
616
|
+
return;
|
|
617
|
+
if (!content) {
|
|
618
|
+
// The index deliberately has not moved: a popup that cannot render must not become the
|
|
619
|
+
// carousel's position, or the dots would point at something the visitor cannot see.
|
|
620
|
+
// Intent rewinds to match, so the next tap steps from what is actually on screen.
|
|
621
|
+
this.pendingIndex = this.index;
|
|
622
|
+
this.callbacks.onRenderFailed(next, 'content_unavailable');
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
this.index = nextIndex;
|
|
626
|
+
this.swapBody(next, content, direction);
|
|
627
|
+
this.callbacks.onOpened(next, 'manual');
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Cross-slides the authored body, leaving the chrome untouched.
|
|
632
|
+
*
|
|
633
|
+
* <p>Only `.np-stage`'s children change, so the underbar, the chips and the dialog itself never
|
|
634
|
+
* re-render — the frame stays put while the content moves through it.
|
|
635
|
+
*/
|
|
636
|
+
swapBody(item, content, direction) {
|
|
637
|
+
const stage = this.root?.querySelector('.np-stage');
|
|
638
|
+
if (!stage)
|
|
639
|
+
return;
|
|
640
|
+
const template = document.createElement('div');
|
|
641
|
+
template.innerHTML = this.bodyFor(item, content);
|
|
642
|
+
const incoming = template.firstElementChild;
|
|
643
|
+
if (!incoming)
|
|
644
|
+
return;
|
|
645
|
+
// Drop any ghost still animating from a previous move, so rapid clicks cannot stack them.
|
|
646
|
+
stage.querySelectorAll('.np-ghost').forEach((ghost) => ghost.remove());
|
|
647
|
+
const outgoing = stage.querySelector('.np-body');
|
|
648
|
+
if (outgoing) {
|
|
649
|
+
if (this.prefersReducedMotion()) {
|
|
650
|
+
outgoing.remove();
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
// Sheds `np-body` as it leaves: while it kept the class there were momentarily two bodies,
|
|
654
|
+
// and everything that reaches for "the body" got the one on its way out.
|
|
655
|
+
outgoing.classList.remove('np-body');
|
|
656
|
+
outgoing.classList.add('np-ghost', direction === 'next' ? 'np-leave-left' : 'np-leave-right');
|
|
657
|
+
const drop = () => outgoing.remove();
|
|
658
|
+
outgoing.addEventListener('animationend', drop, { once: true });
|
|
659
|
+
// `animationend` does not fire if the animation never starts or is cancelled — a hidden
|
|
660
|
+
// tab, a cancelled paint. Without this the ghost would sit over the popup for good.
|
|
661
|
+
window.setTimeout(drop, GHOST_TTL_MS);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (!this.prefersReducedMotion()) {
|
|
665
|
+
incoming.classList.add(direction === 'next' ? 'np-enter-right' : 'np-enter-left');
|
|
666
|
+
}
|
|
667
|
+
stage.appendChild(incoming);
|
|
668
|
+
// Both bodies share one `<style>`, so during the overlap the outgoing one is briefly styled by
|
|
669
|
+
// its successor's CSS. Visible only where two templates differ sharply, and the alternative —
|
|
670
|
+
// a second style element scoped per body — costs more than the artefact it removes.
|
|
671
|
+
this.applyVariantCss(content);
|
|
672
|
+
this.syncDots();
|
|
673
|
+
}
|
|
674
|
+
/** Repoints the dots without rebuilding them, so the underbar never repaints mid-swipe. */
|
|
675
|
+
syncDots() {
|
|
676
|
+
const dots = this.root?.querySelectorAll('.np-dot');
|
|
677
|
+
dots?.forEach((dot, i) => dot.classList.toggle('np-dot-on', i === this.index));
|
|
678
|
+
}
|
|
679
|
+
prefersReducedMotion() {
|
|
680
|
+
return (typeof window !== 'undefined' &&
|
|
681
|
+
typeof window.matchMedia === 'function' &&
|
|
682
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
683
|
+
}
|
|
684
|
+
handleKeyDown(event) {
|
|
685
|
+
if (this.state !== 'visible')
|
|
686
|
+
return;
|
|
687
|
+
if (event.key === 'Escape') {
|
|
688
|
+
this.close();
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (event.key === 'ArrowRight')
|
|
692
|
+
this.move('next');
|
|
693
|
+
if (event.key === 'ArrowLeft')
|
|
694
|
+
this.move('prev');
|
|
695
|
+
if (event.key === 'Tab')
|
|
696
|
+
this.trapFocus(event);
|
|
697
|
+
}
|
|
698
|
+
/** Keeps keyboard focus inside the dialog while it is open. */
|
|
699
|
+
trapFocus(event) {
|
|
700
|
+
const focusable = this.root?.querySelectorAll('a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
701
|
+
if (!focusable || focusable.length === 0)
|
|
702
|
+
return;
|
|
703
|
+
const first = focusable[0];
|
|
704
|
+
const last = focusable[focusable.length - 1];
|
|
705
|
+
const active = this.root?.activeElement;
|
|
706
|
+
if (event.shiftKey && active === first) {
|
|
707
|
+
event.preventDefault();
|
|
708
|
+
last.focus();
|
|
709
|
+
}
|
|
710
|
+
else if (!event.shiftKey && active === last) {
|
|
711
|
+
event.preventDefault();
|
|
712
|
+
first.focus();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
focusDialog() {
|
|
716
|
+
this.root?.querySelector('.np-modal')?.focus();
|
|
717
|
+
}
|
|
718
|
+
handleTouchStart(event) {
|
|
719
|
+
this.touchStartX = event.touches[0]?.clientX ?? null;
|
|
720
|
+
}
|
|
721
|
+
handleTouchEnd(event) {
|
|
722
|
+
if (this.touchStartX === null || this.items.length < 2)
|
|
723
|
+
return;
|
|
724
|
+
const delta = (event.changedTouches[0]?.clientX ?? this.touchStartX) - this.touchStartX;
|
|
725
|
+
this.touchStartX = null;
|
|
726
|
+
if (Math.abs(delta) < 50)
|
|
727
|
+
return;
|
|
728
|
+
this.move(delta < 0 ? 'next' : 'prev');
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Chrome only. The authored body brings its own CSS, injected separately into `#variant-css`.
|
|
733
|
+
*
|
|
734
|
+
* <h3>Why the modal has no surface of its own</h3>
|
|
735
|
+
* A popup authored in the CRM editor is already a complete card — its own width, background and
|
|
736
|
+
* radius. Wrapping it in a fixed-width opaque box painted a white gutter either side of every
|
|
737
|
+
* popup narrower than 900px. The modal is therefore sized by its content and left transparent, so
|
|
738
|
+
* what the visitor sees is exactly what the author designed. Only the states the SDK draws itself
|
|
739
|
+
* — loading, render failure, unsupported schema — carry a surface, because they have no authored
|
|
740
|
+
* card to sit on.
|
|
741
|
+
*
|
|
742
|
+
* <h3>Why the chrome carries its own contrast</h3>
|
|
743
|
+
* The close chip, the arrows and the underbar all sit against authored content — a white card, a
|
|
744
|
+
* dark one, a full-bleed photograph. None of them inherit colour or assume a light surface; each
|
|
745
|
+
* paints a translucent dark fill with a light ring so it stays legible whatever the template does.
|
|
746
|
+
*
|
|
747
|
+
* <p>`contain` is `layout style`, not `layout paint style`: paint containment clips to the border
|
|
748
|
+
* box, and the modal is sized by its content, so anything the chrome overhangs would be swallowed.
|
|
749
|
+
*/
|
|
750
|
+
const CHROME_CSS = `
|
|
751
|
+
:host { all: initial; }
|
|
752
|
+
.np-backdrop {
|
|
753
|
+
position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 2147483646;
|
|
754
|
+
}
|
|
755
|
+
.np-modal {
|
|
756
|
+
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
|
757
|
+
width: auto; min-width: min(320px, 92vw); max-width: min(900px, 92vw);
|
|
758
|
+
max-height: min(760px, 90dvh);
|
|
759
|
+
display: flex; flex-direction: column; overflow: visible;
|
|
760
|
+
background: transparent; z-index: 2147483647;
|
|
761
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
762
|
+
contain: layout style;
|
|
763
|
+
}
|
|
764
|
+
@media (max-width: 767px) {
|
|
765
|
+
.np-modal { width: 96vw; max-width: 96vw; max-height: 90dvh; }
|
|
766
|
+
}
|
|
767
|
+
/* Positioned so the outgoing body can be lifted out of flow and slide across without the stage
|
|
768
|
+
stretching to hold both at once. */
|
|
769
|
+
.np-stage { position: relative; display: flex; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
|
770
|
+
.np-body { overflow: auto; flex: 1 1 auto; }
|
|
771
|
+
/* Out of flow, so the stage sizes to the incoming body alone. */
|
|
772
|
+
.np-ghost {
|
|
773
|
+
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
|
|
774
|
+
}
|
|
775
|
+
.np-enter-right { animation: np-in-right 260ms cubic-bezier(.22,.61,.36,1) both; }
|
|
776
|
+
.np-enter-left { animation: np-in-left 260ms cubic-bezier(.22,.61,.36,1) both; }
|
|
777
|
+
.np-leave-left { animation: np-out-left 260ms cubic-bezier(.55,.06,.68,.19) both; }
|
|
778
|
+
.np-leave-right { animation: np-out-right 260ms cubic-bezier(.55,.06,.68,.19) both; }
|
|
779
|
+
@keyframes np-in-right {
|
|
780
|
+
from { transform: translateX(28%); opacity: 0; }
|
|
781
|
+
to { transform: translateX(0); opacity: 1; }
|
|
782
|
+
}
|
|
783
|
+
@keyframes np-in-left {
|
|
784
|
+
from { transform: translateX(-28%); opacity: 0; }
|
|
785
|
+
to { transform: translateX(0); opacity: 1; }
|
|
786
|
+
}
|
|
787
|
+
@keyframes np-out-left {
|
|
788
|
+
from { transform: translateX(0); opacity: 1; }
|
|
789
|
+
to { transform: translateX(-28%); opacity: 0; }
|
|
790
|
+
}
|
|
791
|
+
@keyframes np-out-right {
|
|
792
|
+
from { transform: translateX(0); opacity: 1; }
|
|
793
|
+
to { transform: translateX(28%); opacity: 0; }
|
|
794
|
+
}
|
|
795
|
+
.np-surface { background: #fff; border-radius: 12px; padding: 24px; }
|
|
796
|
+
.np-fallback { font-size: 18px; text-align: center; }
|
|
797
|
+
.np-loading { text-align: center; color: #888; }
|
|
798
|
+
/* The chips sit over authored content we do not control, so they carry their own contrast: a
|
|
799
|
+
dark translucent fill with a light ring reads on a white card, a dark one and a photograph
|
|
800
|
+
alike. Inheriting or assuming a light surface breaks on half the templates a tenant can
|
|
801
|
+
author. */
|
|
802
|
+
.np-chip {
|
|
803
|
+
position: absolute; z-index: 1; padding: 0; cursor: pointer;
|
|
804
|
+
background: rgba(15,23,42,.62); color: #fff;
|
|
805
|
+
border: 1px solid rgba(255,255,255,.38); border-radius: 50%;
|
|
806
|
+
box-shadow: 0 2px 10px rgba(0,0,0,.3);
|
|
807
|
+
display: flex; align-items: center; justify-content: center;
|
|
808
|
+
font-family: inherit; line-height: 1;
|
|
809
|
+
}
|
|
810
|
+
/* Inside the card's own corner rather than hanging off it: outside, the chip lands on the
|
|
811
|
+
tenant's page and reads as page furniture instead of part of the popup. */
|
|
812
|
+
.np-close { top: 10px; right: 10px; width: 30px; height: 30px; font-size: 18px; }
|
|
813
|
+
.np-nav { top: 50%; transform: translateY(-50%); width: 34px; height: 34px; font-size: 20px; }
|
|
814
|
+
.np-prev { left: 10px; }
|
|
815
|
+
.np-next { right: 10px; }
|
|
816
|
+
/* Attached to the card rather than floating below it: with no surface of its own the chrome
|
|
817
|
+
reads as stray page content, especially over a busy host page. */
|
|
818
|
+
.np-underbar {
|
|
819
|
+
display: flex; gap: 12px; align-items: center; justify-content: space-between;
|
|
820
|
+
flex-wrap: wrap; flex: 0 0 auto;
|
|
821
|
+
padding: 12px 18px;
|
|
822
|
+
background: rgba(15,23,42,.72); border-radius: 0 0 14px 14px;
|
|
823
|
+
}
|
|
824
|
+
/* Nothing to separate when the row holds a single control, so it centres instead. */
|
|
825
|
+
.np-underbar:has(> :only-child) { justify-content: center; }
|
|
826
|
+
.np-cta {
|
|
827
|
+
padding: 10px 18px; border-radius: 8px; background: #fff; color: #111;
|
|
828
|
+
text-decoration: none; font-size: 14px;
|
|
829
|
+
}
|
|
830
|
+
.np-dismiss {
|
|
831
|
+
background: transparent; border: 0; color: rgba(255,255,255,.78);
|
|
832
|
+
font-size: 13px; cursor: pointer; text-decoration: underline; font-family: inherit;
|
|
833
|
+
padding: 0;
|
|
834
|
+
}
|
|
835
|
+
.np-dots { display: flex; gap: 6px; align-items: center; }
|
|
836
|
+
.np-dot { width: 7px; height: 7px; border-radius: 50%; background: rgba(255,255,255,.4); }
|
|
837
|
+
.np-dot-on { background: #fff; }
|
|
838
|
+
@media (prefers-reduced-motion: reduce) {
|
|
839
|
+
.np-modal, .np-backdrop { transition: none !important; animation: none !important; }
|
|
840
|
+
/* Belt and braces: swapBody already skips the classes, but a body inserted before the media
|
|
841
|
+
query is re-evaluated must not animate either. */
|
|
842
|
+
.np-body { animation: none !important; }
|
|
843
|
+
}
|
|
844
|
+
`;
|
|
845
|
+
/**
|
|
846
|
+
* Launcher chrome, in its own shadow root on its own host — see {@link PopupViewer.mountLauncher}
|
|
847
|
+
* for why the launcher cannot share the modal's host.
|
|
848
|
+
*
|
|
849
|
+
* <p>The `@keyframes` below have to live here rather than in {@link CHROME_CSS}: keyframe names
|
|
850
|
+
* resolve per shadow root, so a rule in one root cannot reference an animation declared in another.
|
|
851
|
+
*/
|
|
852
|
+
const LAUNCHER_CSS = `
|
|
853
|
+
:host { all: initial; }
|
|
854
|
+
/* \`all: initial\` computes \`display: inline\`, which would collapse the button's box when the
|
|
855
|
+
host sits in the tenant's own flow. Only the anchored case needs the correction — floating
|
|
856
|
+
positions the wrap absolutely, so the host's own display never matters there.
|
|
857
|
+
|
|
858
|
+
\`color\` is deliberately let back in past that same reset, and only here: an anchored launcher
|
|
859
|
+
has no surface of its own, so the glyph has to take the tenant's own text colour or it would
|
|
860
|
+
be invisible on a dark header. Higher specificity than the \`:host\` reset above, so it wins. */
|
|
861
|
+
:host([data-anchored]) { display: inline-block; vertical-align: middle; color: inherit; }
|
|
862
|
+
/* The wrap carries the position so the hide chip has something to anchor to; the button keeps
|
|
863
|
+
its own \`part\` so a tenant can restyle either half from their page. Bottom-LEFT by default —
|
|
864
|
+
bottom-right is where our own chatbot embed and most third-party widgets live. */
|
|
865
|
+
#launcher-wrap { position: fixed; left: 20px; bottom: 20px; z-index: 2147483645; }
|
|
866
|
+
/* Anchored: hand positioning back to the tenant's layout. The badge still lands on the button's
|
|
867
|
+
corner because the button itself stays \`position: relative\`. */
|
|
868
|
+
:host([data-anchored]) #launcher-wrap { position: static; }
|
|
869
|
+
/* \`box-sizing\` and \`padding\` are explicit because the UA button stylesheet otherwise adds its
|
|
870
|
+
own padding outside a content-box width, inflating the circle past the size set here. */
|
|
871
|
+
#launcher {
|
|
872
|
+
position: relative; box-sizing: border-box; padding: 0;
|
|
873
|
+
display: flex; align-items: center; justify-content: center;
|
|
874
|
+
width: 52px; height: 52px; border-radius: 50%; border: 0; cursor: pointer;
|
|
875
|
+
background: #111; color: #fff; font-size: 22px;
|
|
876
|
+
}
|
|
877
|
+
/* Sized in \`em\` so the bell follows the button down to its smaller anchored size. */
|
|
878
|
+
#launcher svg { width: 1.1em; height: 1.1em; display: block; }
|
|
879
|
+
/* Anchored, the launcher is one icon among the tenant's own header actions, not a widget sitting
|
|
880
|
+
on top of their page: it drops the dark disc entirely and takes their text colour, so it
|
|
881
|
+
inherits their theme instead of imposing ours. The glyph runs larger than the floating
|
|
882
|
+
\`1.1em\` would give at this size, because without a surface behind it there is nothing to
|
|
883
|
+
anchor the eye. The badge keeps its red, which is the one thing that must not blend in. */
|
|
884
|
+
:host([data-anchored]) #launcher {
|
|
885
|
+
width: 36px; height: 36px; font-size: 20px;
|
|
886
|
+
background: transparent; color: inherit;
|
|
887
|
+
}
|
|
888
|
+
#badge {
|
|
889
|
+
position: absolute; top: -4px; right: -4px; min-width: 20px; height: 20px;
|
|
890
|
+
border-radius: 10px; background: #e5484d; color: #fff; font-size: 12px; line-height: 20px;
|
|
891
|
+
}
|
|
892
|
+
#launcher-hide {
|
|
893
|
+
position: absolute; top: -6px; left: -6px; width: 18px; height: 18px;
|
|
894
|
+
border-radius: 50%; border: 0; cursor: pointer; padding: 0;
|
|
895
|
+
background: #555; color: #fff; font-size: 12px; line-height: 18px;
|
|
896
|
+
}
|
|
897
|
+
/* Rings continuously for as long as anything is unread — the launcher only exists in that
|
|
898
|
+
state, so there is no case where this animates over nothing. */
|
|
899
|
+
#launcher.np-attention { animation: np-wiggle .55s ease-in-out infinite; }
|
|
900
|
+
#launcher.np-shake { animation: np-shake .6s ease-in-out 1; }
|
|
901
|
+
#badge.np-pulse { animation: np-pulse .6s ease-out 1; }
|
|
902
|
+
@keyframes np-wiggle {
|
|
903
|
+
0%, 100% { transform: rotate(-9deg); }
|
|
904
|
+
50% { transform: rotate(9deg); }
|
|
905
|
+
}
|
|
906
|
+
@keyframes np-shake {
|
|
907
|
+
0%, 100% { transform: rotate(0deg) scale(1); }
|
|
908
|
+
15%, 45%, 75% { transform: rotate(-12deg) scale(1.08); }
|
|
909
|
+
30%, 60%, 90% { transform: rotate(12deg) scale(1.08); }
|
|
910
|
+
}
|
|
911
|
+
@keyframes np-pulse {
|
|
912
|
+
0% { transform: scale(1); }
|
|
913
|
+
50% { transform: scale(1.5); }
|
|
914
|
+
100% { transform: scale(1); }
|
|
915
|
+
}
|
|
916
|
+
@media (prefers-reduced-motion: reduce) {
|
|
917
|
+
#launcher, #badge { transition: none !important; animation: none !important; }
|
|
918
|
+
}
|
|
919
|
+
`;
|
|
920
|
+
//# sourceMappingURL=popup-viewer.js.map
|