@luigisbox/luma 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -75
- package/build/vite.mjs +9 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +10 -5
- package/docs/ACCESSIBILITY.md +1 -2
- package/docs/INTEGRATION-PERFORMANCE.md +5 -6
- package/package.json +1 -1
- package/parity/hold.mjs +1 -1
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/token.ts +3 -2
- package/src/core/integration.ts +6 -0
- package/src/core/kept.ts +25 -0
- package/src/core/tiles.ts +137 -0
- package/src/main.ts +54 -4
- package/src/types.ts +18 -0
- package/src/widgets/autocomplete/index.ts +20 -0
- package/src/widgets/listing/element.ts +3 -0
- package/src/widgets/recommender/index.ts +15 -4
package/src/main.ts
CHANGED
|
@@ -21,10 +21,11 @@ import type { T } from './core/i18n';
|
|
|
21
21
|
import { failures, holdCss, MAX_FAILURES, painted, reserveCss } from './core/hold';
|
|
22
22
|
import { hostStyle, LISTINGS, loadConfig, loadTranslations, widgetFiles, type IntegrationFiles } from './core/integration';
|
|
23
23
|
import type { Templates } from './core/templates';
|
|
24
|
-
import { plpAccepts, searchAccepts } from './core/routes';
|
|
24
|
+
import { plpAccepts, searchAccepts, searchReadQuery } from './core/routes';
|
|
25
25
|
import { clearEarlyRules, earlyHold, plpRule, searchRule, writeEarlyRules, type EarlyRoute } from './core/early-rules';
|
|
26
26
|
import { askChallenge, storedToken } from './api/token-key';
|
|
27
27
|
import { loadDictionary } from './core/locales';
|
|
28
|
+
import { keepDl } from './core/kept';
|
|
28
29
|
import './core/boot-state'; // the page's state at boot, for lbx-place's reconciler
|
|
29
30
|
import { onFlush, schedule } from './core/scheduler';
|
|
30
31
|
import { onUrlChange, startSensing } from './core/sense';
|
|
@@ -153,11 +154,19 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
153
154
|
askChallenge(g); // while the minting code loads
|
|
154
155
|
import('./api/token').then((m) => m.warmToken(g)).catch((e) => reportError('token', e));
|
|
155
156
|
};
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
const afterLoad = (f: () => void) => {
|
|
158
|
+
const idle = () => (typeof requestIdleCallback === 'function' ? requestIdleCallback(f, { timeout: 2000 }) : setTimeout(f, 200));
|
|
159
|
+
if (document.readyState === 'complete') idle();
|
|
160
|
+
else addEventListener('load', idle, { once: true });
|
|
161
|
+
};
|
|
162
|
+
afterLoad(warm);
|
|
159
163
|
startSensing();
|
|
160
164
|
|
|
165
|
+
// Analytics starts once the page has loaded, or with the first widget (its
|
|
166
|
+
// setup then listens before a tile is clicked). Set up at the end of boot.
|
|
167
|
+
let analyticsWanted = false;
|
|
168
|
+
let startAnalytics = () => void (analyticsWanted = true);
|
|
169
|
+
|
|
161
170
|
// --- lazy widget loading: code + templates + styles + dictionaries in parallel
|
|
162
171
|
const locale = g.locale ?? 'en';
|
|
163
172
|
const loaded = new Map<string, Promise<Loaded>>();
|
|
@@ -165,6 +174,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
165
174
|
if (loaded.has(id)) return loaded.get(id)!;
|
|
166
175
|
mark(`load ${id}`);
|
|
167
176
|
warm();
|
|
177
|
+
startAnalytics();
|
|
168
178
|
const support = import('./core/support');
|
|
169
179
|
const p = Promise.all([
|
|
170
180
|
modules[kind]() as Promise<any>,
|
|
@@ -404,6 +414,8 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
404
414
|
// boot where a popup one is configured (its trigger may fire on any page).
|
|
405
415
|
const recos: Reco[] = [];
|
|
406
416
|
const pages: ((u: URL) => boolean)[] = [];
|
|
417
|
+
/** Our recommenders by name, and the pages each runs on: where ours runs, the site's of that name is not tracked. */
|
|
418
|
+
const recoPages = new Map<string, (u: URL) => boolean>();
|
|
407
419
|
for (const [name, rc] of Object.entries(config.recommenders ?? {})) {
|
|
408
420
|
if (rc.disabled) continue;
|
|
409
421
|
const id = `reco-${name}`;
|
|
@@ -417,6 +429,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
417
429
|
recos.push({ id, rc, get: () => load('recommender', id, folders, rc, ['recommender']) });
|
|
418
430
|
const re = !rc.confirm && rc.urlMatch ? setUp(id, () => new RegExp(rc.urlMatch!)) : null;
|
|
419
431
|
pages.push(rc.confirm ? () => true : (u) => (rc.active ? rc.active(u) : !re || re.test(u.pathname)));
|
|
432
|
+
recoPages.set(name, pages[pages.length - 1]);
|
|
420
433
|
}
|
|
421
434
|
if (recos.length) {
|
|
422
435
|
let asked = false;
|
|
@@ -439,5 +452,42 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
439
452
|
onFlush(() => recent() && check());
|
|
440
453
|
}
|
|
441
454
|
|
|
455
|
+
// --- analytics (src/analytics, lbx-analytics): where the integration has an
|
|
456
|
+
// _analytics.ts (nothing is sent that it does not say), every page once it
|
|
457
|
+
// has loaded or with the first widget (its chains on our tiles listen then).
|
|
458
|
+
// Without one, `analytics: false`: our tiles queue nothing (core/tiles.ts).
|
|
459
|
+
if (!Object.keys(files.analytics ?? {}).length) g.analytics = false;
|
|
460
|
+
if (g.analytics !== false) {
|
|
461
|
+
const here = () => new URL(location.href);
|
|
462
|
+
const readQ = search ? searchReadQuery(search) : null;
|
|
463
|
+
const owns = (s: string) => {
|
|
464
|
+
if (s === 'autocomplete') return !!(ac && !ac.disabled && g.inputSelector);
|
|
465
|
+
if (s === 'search') return routes.some((r) => r.kind === 'search' && r.accepts(here()));
|
|
466
|
+
const reco = s.startsWith('recommender:') ? recoPages.get(s.slice(12)) : undefined;
|
|
467
|
+
return !!reco && !!setUp(`${s} active`, () => reco(here()));
|
|
468
|
+
};
|
|
469
|
+
const visit = (u: URL) => {
|
|
470
|
+
const r = routes.find((x) => x.accepts(u));
|
|
471
|
+
return !r ? null : r.kind === 'plp' ? `plp:${u.origin}${u.pathname}` : `search:${u.origin}${u.pathname}?${readQ?.(u) ?? ''}`;
|
|
472
|
+
};
|
|
473
|
+
let started = false;
|
|
474
|
+
/** Analytics listens (the setup has run). Until then, a page left keeps its dataLayer pushes for the next one's (core/kept.ts), up to `kept`. */
|
|
475
|
+
let live = false;
|
|
476
|
+
let kept = 0;
|
|
477
|
+
addEventListener('pagehide', () => {
|
|
478
|
+
if (!live && !(document as any).prerendering) kept = keepDl(g.dataLayer ?? 'dataLayer', kept);
|
|
479
|
+
});
|
|
480
|
+
startAnalytics = () => {
|
|
481
|
+
if (started) return;
|
|
482
|
+
started = true;
|
|
483
|
+
import('./analytics/start')
|
|
484
|
+
.then((m) => m.start({ config, files: files.analytics ?? {}, owns, visit, onUrlChange, kept: () => kept }))
|
|
485
|
+
.then(() => (live = true))
|
|
486
|
+
.catch((e) => reportError('analytics', e));
|
|
487
|
+
};
|
|
488
|
+
if (analyticsWanted) startAnalytics();
|
|
489
|
+
else afterLoad(() => startAnalytics());
|
|
490
|
+
}
|
|
491
|
+
|
|
442
492
|
schedule();
|
|
443
493
|
}
|
package/src/types.ts
CHANGED
|
@@ -94,6 +94,24 @@ export interface GlobalConfigType {
|
|
|
94
94
|
/** Set in _init.ts: templates read them as r.c.<name>. Defaults: title, url, image, price. */
|
|
95
95
|
computedAttributes?: Record<string, (r: Result) => unknown>;
|
|
96
96
|
translations?: Translations;
|
|
97
|
+
/**
|
|
98
|
+
* `false`: no analytics at all, whatever `_analytics.ts` says (README
|
|
99
|
+
* "Analytics": what is sent is that file's).
|
|
100
|
+
*/
|
|
101
|
+
analytics?: false;
|
|
102
|
+
/**
|
|
103
|
+
* The site's dataLayer, for analytics' `dl()` (default `dataLayer`): here,
|
|
104
|
+
* not in `_analytics.ts`, because the entry keeps its pushes for the next
|
|
105
|
+
* page when the visitor leaves before analytics has run.
|
|
106
|
+
*/
|
|
107
|
+
dataLayer?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Puts a product in the site's cart (`_init.ts`, or `_global/_widget.ts`:
|
|
110
|
+
* only widgets call it): what a click on `[data-lbx-cart]` in one of our
|
|
111
|
+
* tiles runs (announced to analytics first: `lumaCarts()`). `count`: the
|
|
112
|
+
* attribute's value (default 1).
|
|
113
|
+
*/
|
|
114
|
+
addToCart?: (r: Result, o: { count: number; el: HTMLElement; event: MouseEvent }) => unknown;
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
/**
|
|
@@ -27,6 +27,7 @@ import { cspNonce, setCss } from '../../core/styles';
|
|
|
27
27
|
import type { WidgetContext } from '../../main';
|
|
28
28
|
import type { AutocompleteConfigType, AutocompleteScope, Result } from '../../types';
|
|
29
29
|
import css from './style-autocomplete.css?raw';
|
|
30
|
+
import { picked } from '../../core/tiles';
|
|
30
31
|
|
|
31
32
|
export const templates = byName(import.meta.glob('./template-*.ts', { eager: true, import: 'default' }));
|
|
32
33
|
|
|
@@ -274,6 +275,13 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
274
275
|
const intoMirror = (e: Event) => sheet && e.composedPath()[0] === mirror;
|
|
275
276
|
box.addEventListener('pointerdown', (e) => e.pointerType !== 'touch' && !intoMirror(e) && e.preventDefault());
|
|
276
277
|
box.addEventListener('mousedown', (e) => !intoMirror(e) && e.preventDefault());
|
|
278
|
+
// A middle click opens a suggestion in a new tab (no choose()): a click too (core/tiles.ts).
|
|
279
|
+
box.addEventListener('auxclick', (e) => {
|
|
280
|
+
if ((e as MouseEvent).button !== 1) return;
|
|
281
|
+
const a = (e.composedPath()[0] as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
|
|
282
|
+
const i = a ? items.findIndex((r) => r.type !== 'query' && !!r.c.url && new URL(r.c.url, location.href).href === a.href) : -1;
|
|
283
|
+
if (i >= 0) track(i);
|
|
284
|
+
});
|
|
277
285
|
// "Show all products" has focus (Tab from the input): Shift+Tab goes back
|
|
278
286
|
// to the input, Escape closes and goes back, Tab moves on as from the
|
|
279
287
|
// input (focus put back on it, the browser's own Tab then leaves it: the
|
|
@@ -1123,12 +1131,24 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1123
1131
|
if (notCancelled && i.form) i.form.requestSubmit();
|
|
1124
1132
|
};
|
|
1125
1133
|
|
|
1134
|
+
/**
|
|
1135
|
+
* A suggestion followed (a click on it, core/tiles.ts). Not a
|
|
1136
|
+
* query suggestion (the search it starts is the API's), nor a "No results"
|
|
1137
|
+
* link of the configuration: the fallback strip's products are clicks.
|
|
1138
|
+
*/
|
|
1139
|
+
const track = (i: number) => {
|
|
1140
|
+
const r = items[i];
|
|
1141
|
+
const kind = (r as any)?.__empty;
|
|
1142
|
+
if (r && r.type !== 'query' && (!kind || kind === 'fallback')) picked(r, i, 'lbx_autocomplete', ctx.global);
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1126
1145
|
function choose(idx: number) {
|
|
1127
1146
|
const r = items[idx];
|
|
1128
1147
|
if (!r) return;
|
|
1129
1148
|
if (r.type === 'query') submitViaHost(plain(r.c.title));
|
|
1130
1149
|
else if (safeUrl(r.c.url)) {
|
|
1131
1150
|
const url = safeUrl(r.c.url);
|
|
1151
|
+
track(idx);
|
|
1132
1152
|
hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
|
|
1133
1153
|
leave('select', () => location.assign(url));
|
|
1134
1154
|
}
|
|
@@ -38,6 +38,7 @@ import type { Page } from '../../api';
|
|
|
38
38
|
import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
|
|
39
39
|
import { dropStash, readStash, writeStash, type Stash } from './stash';
|
|
40
40
|
import { keepViewport, leavingLink, nearEnd, preconnectImages, restoreScroll, scrollSpot } from './instant';
|
|
41
|
+
import { watch } from '../../core/tiles';
|
|
41
42
|
|
|
42
43
|
/** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
|
|
43
44
|
const FADE_FROM = 0.6;
|
|
@@ -392,6 +393,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
392
393
|
if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', t('label'));
|
|
393
394
|
if (!this.hasAttribute('tabindex')) this.tabIndex = -1;
|
|
394
395
|
this.unsub = onUrlChange(() => this.resync());
|
|
396
|
+
// Product clicks and add-to-carts on our tiles: the framework's, never a template's (core/tiles.ts).
|
|
397
|
+
watch(this, { list: `lbx_${route.id}`, global: ctx.global, results: () => this.last?.hits.map(ctx.result) ?? [] });
|
|
395
398
|
this.addEventListener('click', this.onClick);
|
|
396
399
|
this.addEventListener('keydown', this.onKey);
|
|
397
400
|
this.addEventListener('focusout', this.onFocusOut);
|
|
@@ -18,7 +18,8 @@ import { onUrlChange } from '../../core/sense';
|
|
|
18
18
|
import { setCss } from '../../core/styles';
|
|
19
19
|
import { settleImages } from '../../core/support';
|
|
20
20
|
import type { WidgetContext } from '../../main';
|
|
21
|
-
import type { RecommenderConfigType } from '../../types';
|
|
21
|
+
import type { RecommenderConfigType, Result } from '../../types';
|
|
22
|
+
import { watch } from '../../core/tiles';
|
|
22
23
|
|
|
23
24
|
export { default as css } from './style-recommender.css?raw';
|
|
24
25
|
export const templates = byName(import.meta.glob('./template-*.ts', { eager: true, import: 'default' }));
|
|
@@ -52,6 +53,9 @@ const fetchHits = (ctx: Ctx, items: string[], signal?: AbortSignal) => {
|
|
|
52
53
|
return signal ? untilAborted(p, signal) : p;
|
|
53
54
|
};
|
|
54
55
|
|
|
56
|
+
/** Our list, for its tiles' clicks and add-to-carts (core/tiles.ts). */
|
|
57
|
+
const listOf = (ctx: Ctx) => ({ list: `lbx_reco_${ctx.id.slice(5)}`, global: ctx.global });
|
|
58
|
+
|
|
55
59
|
const applyStyles = (ctx: Ctx) => {
|
|
56
60
|
ctx.styles.forEach((c, i) => setCss(`itg:${ctx.id}:${i}`, c));
|
|
57
61
|
useContrast();
|
|
@@ -67,6 +71,8 @@ interface State {
|
|
|
67
71
|
tried: string | null;
|
|
68
72
|
failures: number;
|
|
69
73
|
retryAt: number;
|
|
74
|
+
/** What the tiles show (analytics: a click's result). */
|
|
75
|
+
results: Result[];
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
/** Retry a failed widget after 2, 4, 8, 16 s, then only when its inputs change. */
|
|
@@ -90,7 +96,8 @@ const load = (ctx: Ctx, el: HTMLElement, s: State, items: string[], key: string)
|
|
|
90
96
|
fetchHits(ctx, items, ctl.signal)
|
|
91
97
|
.then((hits) => {
|
|
92
98
|
if (ctl.signal.aborted) return;
|
|
93
|
-
s.
|
|
99
|
+
s.results = hits.map(ctx.result);
|
|
100
|
+
s.v.render('recommender-box-default', { ...ctx.helpers, results: s.results });
|
|
94
101
|
settleImages(el, ctx.global.images?.fade !== false);
|
|
95
102
|
el.style.minHeight = '';
|
|
96
103
|
el.dataset.state = hits.length ? 'ready' : 'empty';
|
|
@@ -115,8 +122,9 @@ const load = (ctx: Ctx, el: HTMLElement, s: State, items: string[], key: string)
|
|
|
115
122
|
/** Called by the entry once the placeholder is near the viewport. */
|
|
116
123
|
export const attach = (ctx: Ctx, el: HTMLElement) => {
|
|
117
124
|
applyStyles(ctx);
|
|
118
|
-
const s: State = { near: true, key: null, v: view(el, ctx.templates), tried: null, failures: 0, retryAt: 0 };
|
|
125
|
+
const s: State = { near: true, key: null, v: view(el, ctx.templates), tried: null, failures: 0, retryAt: 0, results: [] };
|
|
119
126
|
attached.set(el, { ctx, s });
|
|
127
|
+
watch(el, { ...listOf(ctx), results: () => s.results });
|
|
120
128
|
io.observe(el);
|
|
121
129
|
sync();
|
|
122
130
|
};
|
|
@@ -180,6 +188,7 @@ let pop: HTMLElement | null = null;
|
|
|
180
188
|
let popView: ReturnType<typeof view> | null = null;
|
|
181
189
|
let isOpen = false;
|
|
182
190
|
let popCtl: AbortController | null = null;
|
|
191
|
+
let popResults: Result[] = [];
|
|
183
192
|
|
|
184
193
|
const close = () => {
|
|
185
194
|
popCtl?.abort();
|
|
@@ -217,6 +226,7 @@ export const openPopup = (ctx: Ctx, item: string, near: DOMRect, notice?: Elemen
|
|
|
217
226
|
// A modal library may set pointer-events: none on body, which inherits.
|
|
218
227
|
pop.style.pointerEvents = 'auto';
|
|
219
228
|
popView = view(pop, ctx.templates);
|
|
229
|
+
watch(pop, { ...listOf(ctx), results: () => popResults });
|
|
220
230
|
// Dismissal: close button, Escape, a press outside, or leaving the page.
|
|
221
231
|
addEventListener('keydown', (e) => isOpen && e.key === 'Escape' && close(), true);
|
|
222
232
|
addEventListener('pointerdown', (e) => isOpen && !pop!.contains(e.target as Node) && close(), true);
|
|
@@ -231,7 +241,8 @@ export const openPopup = (ctx: Ctx, item: string, near: DOMRect, notice?: Elemen
|
|
|
231
241
|
isOpen = false;
|
|
232
242
|
}
|
|
233
243
|
pop.setAttribute('aria-label', ctx.t('title'));
|
|
234
|
-
|
|
244
|
+
popResults = hits.map(ctx.result);
|
|
245
|
+
popView!.render('recommender-popup', { ...ctx.helpers, results: popResults, close });
|
|
235
246
|
settleImages(pop, ctx.global.images?.fade !== false);
|
|
236
247
|
pop.querySelector('[data-lbx-list]')?.scrollTo(0, 0);
|
|
237
248
|
// Just below the host's notice, right-aligned with it.
|