@luigisbox/luma 0.1.0 → 0.3.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 +208 -76
- package/build/vite.mjs +9 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +10 -5
- package/docs/ACCESSIBILITY.md +9 -5
- 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/entries.ts +10 -6
- package/src/core/integration.ts +6 -0
- package/src/core/kept.ts +25 -0
- package/src/core/stash.ts +24 -4
- package/src/core/tiles.ts +137 -0
- package/src/main.ts +54 -4
- package/src/types.ts +29 -2
- package/src/widgets/autocomplete/index.ts +206 -31
- package/src/widgets/autocomplete/style-autocomplete.css +11 -5
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
- package/src/widgets/listing/element.ts +212 -21
- package/src/widgets/listing/instant.ts +83 -23
- package/src/widgets/listing/stash.ts +31 -8
- package/src/widgets/listing/view.ts +12 -2
- package/src/widgets/recommender/index.ts +15 -4
- package/src/widgets/search/range.ts +40 -18
- package/src/widgets/search/style-search.css +2 -2
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Clicks and add-to-carts on our own tiles, seen by the framework: no template
|
|
2
|
+
// says anything about them. Every widget that renders products calls
|
|
3
|
+
// `watch()` on its root once (listings, recommenders, the popup) or `picked()`
|
|
4
|
+
// (the autocomplete's own navigation); the rest follows from the markup:
|
|
5
|
+
//
|
|
6
|
+
// - a product click is a click (any button: a middle click, a modifier opens
|
|
7
|
+
// a tab, still a click) on a link to a result's URL inside a list of ours
|
|
8
|
+
// (`[data-lbx-list]`); the result is the one with that URL (the tile's own
|
|
9
|
+
// when two share it), else, where the integration's link is not `r.c.url`,
|
|
10
|
+
// the tile's at its position (tiles map to hits by position, as the view
|
|
11
|
+
// transitions and focus code assume). Other links in a tile (wishlist,
|
|
12
|
+
// compare) are not product clicks.
|
|
13
|
+
// - an add-to-cart is a click on `[data-lbx-cart]` in a tile (its value: the
|
|
14
|
+
// quantity, default 1): `_global.addToCart(result, { count, el, event })`
|
|
15
|
+
// puts it in the site's cart. Without that hook nothing happens (and it
|
|
16
|
+
// says so).
|
|
17
|
+
//
|
|
18
|
+
// Each is announced as an `lbx:tile` event on document (TileEvent): analytics
|
|
19
|
+
// listens only where the integration's _analytics.ts asks (its `lumaClicks()`,
|
|
20
|
+
// `lumaCarts()` triggers), so a widget brings no analytics code. Analytics may
|
|
21
|
+
// not be listening yet (its chunk and the setup come after the widget's), or
|
|
22
|
+
// the click may leave the page: so each is first queued in sessionStorage, in
|
|
23
|
+
// the click's own task, where analytics claims it, on this page or the next.
|
|
24
|
+
// Not where the integration has no analytics (`_global.analytics` false: the
|
|
25
|
+
// entry sets it where there is no _analytics.ts).
|
|
26
|
+
|
|
27
|
+
import { reportError } from './health';
|
|
28
|
+
import { session } from './storage';
|
|
29
|
+
import type { GlobalConfigType, Result } from '../types';
|
|
30
|
+
|
|
31
|
+
/** `detail` of an `lbx:tile` event. */
|
|
32
|
+
export interface TileEvent {
|
|
33
|
+
/** Its id in the queue. */
|
|
34
|
+
id: string;
|
|
35
|
+
kind: 'click' | 'add_to_cart';
|
|
36
|
+
/** `<type>/@id:<id>`: the result's reference. */
|
|
37
|
+
reference: string;
|
|
38
|
+
result: Result;
|
|
39
|
+
/** Its position in the list (0-based). */
|
|
40
|
+
index: number;
|
|
41
|
+
/** Our list: `lbx_search`, `lbx_plp`, `lbx_reco_<name>`, `lbx_autocomplete`. */
|
|
42
|
+
list: string;
|
|
43
|
+
count?: number;
|
|
44
|
+
price?: number;
|
|
45
|
+
/** The page it happened on, and when (ms epoch): what the event carries, sent later or not. */
|
|
46
|
+
url: string;
|
|
47
|
+
at: number;
|
|
48
|
+
}
|
|
49
|
+
export interface Watched {
|
|
50
|
+
list: string;
|
|
51
|
+
/** The results the list shows now, in tile order. */
|
|
52
|
+
results: () => readonly Result[];
|
|
53
|
+
global: GlobalConfigType;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The queue analytics claims from (src/analytics/triggers.ts). */
|
|
57
|
+
export const TILES = 'lbx:tiles';
|
|
58
|
+
const announce = (g: GlobalConfigType, e: Omit<TileEvent, 'id' | 'url' | 'at'>) => {
|
|
59
|
+
if (g.analytics === false) return;
|
|
60
|
+
const d = { ...e, id: crypto.randomUUID(), url: location.href, at: Date.now() };
|
|
61
|
+
session.set(TILES, [...(session.get<TileEvent[]>(TILES) ?? []), d].slice(-20));
|
|
62
|
+
try {
|
|
63
|
+
document.dispatchEvent(new CustomEvent('lbx:tile', { detail: d }));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
reportError('tile event', err);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const reference = (r: Result) => `${r.type || 'product'}/@id:${r.id}`;
|
|
69
|
+
|
|
70
|
+
/** A result clicked (a link to it, or our own navigation: the autocomplete). */
|
|
71
|
+
export const picked = (r: Result, index: number, list: string, g: GlobalConfigType) => announce(g, { kind: 'click', reference: reference(r), result: r, index, list });
|
|
72
|
+
|
|
73
|
+
const watched = new WeakSet<Element>();
|
|
74
|
+
/** Product clicks and add-to-carts inside `root` (once per root). */
|
|
75
|
+
export const watch = (root: Element, w: Watched) => {
|
|
76
|
+
if (watched.has(root)) return;
|
|
77
|
+
watched.add(root);
|
|
78
|
+
const on = (e: MouseEvent) => {
|
|
79
|
+
if (e.type === 'auxclick' && e.button !== 1) return;
|
|
80
|
+
try {
|
|
81
|
+
const t = e.target instanceof Element ? e.target : null;
|
|
82
|
+
const tile = t?.closest('[data-lbx-list] > *');
|
|
83
|
+
if (!tile || !root.contains(tile)) return;
|
|
84
|
+
const results = w.results();
|
|
85
|
+
const tiles = [...tile.parentElement!.children];
|
|
86
|
+
const at = tiles.indexOf(tile);
|
|
87
|
+
const cart = t!.closest<HTMLElement>('[data-lbx-cart]');
|
|
88
|
+
if (cart && tile.contains(cart)) return void (e.type === 'click' && toCart(e, cart, tile, at, tiles.length, results, w));
|
|
89
|
+
const a = t!.closest<HTMLAnchorElement>('a[href]');
|
|
90
|
+
if (!a || !tile.contains(a)) return;
|
|
91
|
+
const r = resultOf(a, at, tiles.length, results);
|
|
92
|
+
if (r) picked(r.r, r.i, w.list, w.global);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
reportError('tile click', err);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
// Capture: before a handler of the tile (or the site's) stops it or navigates.
|
|
98
|
+
root.addEventListener('click', on as EventListener, true);
|
|
99
|
+
root.addEventListener('auxclick', on as EventListener, true);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const sameUrl = (r: Result, href: string) => {
|
|
103
|
+
try {
|
|
104
|
+
return !!r.c.url && new URL(r.c.url, location.href).href === href;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The result a link in the tile at `at` leads to. */
|
|
111
|
+
const resultOf = (a: HTMLAnchorElement, at: number, n: number, results: readonly Result[]) => {
|
|
112
|
+
const same = results.flatMap((r, i) => (sameUrl(r, a.href) ? [{ r, i }] : []));
|
|
113
|
+
if (same.length) return same.find((x) => x.i === at) ?? same[0];
|
|
114
|
+
// The integration's link is not r.c.url (its own params): the tile's, where tiles are the results.
|
|
115
|
+
if (n === results.length && results[at] && a.origin === location.origin && !a.matches('[data-remote], [data-method], [download]')) return { r: results[at], i: at };
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const toCart = (e: MouseEvent, el: HTMLElement, tile: Element, at: number, n: number, results: readonly Result[], w: Watched) => {
|
|
120
|
+
// A cart button inside the tile's link must not follow it.
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
e.stopPropagation();
|
|
123
|
+
const link = [...tile.querySelectorAll<HTMLAnchorElement>('a[href]')].map((a) => resultOf(a, at, n, results)).find(Boolean);
|
|
124
|
+
const hit = link ?? (n === results.length && results[at] ? { r: results[at], i: at } : null);
|
|
125
|
+
if (!hit) return reportError('add to cart', 'a [data-lbx-cart] in a tile whose result is unknown (its links lead to no result, and tiles are not the results)');
|
|
126
|
+
const hook = w.global.addToCart;
|
|
127
|
+
if (typeof hook !== 'function') return reportError('add to cart', '[data-lbx-cart] needs _global.addToCart (_init.ts or _global/_widget.ts) to put the product in the site\'s cart');
|
|
128
|
+
const count = Math.max(1, Math.round(Number(el.dataset.lbxCart) || 1));
|
|
129
|
+
const price = typeof hit.r.c.price === 'number' ? hit.r.c.price : undefined;
|
|
130
|
+
announce(w.global, { kind: 'add_to_cart', reference: reference(hit.r), result: hit.r, index: hit.i, list: w.list, count, ...(price !== undefined ? { price } : {}) });
|
|
131
|
+
try {
|
|
132
|
+
const p = hook(hit.r, { count, el, event: e });
|
|
133
|
+
if (p && typeof (p as Promise<unknown>).catch === 'function') (p as Promise<unknown>).catch((err) => reportError('addToCart', err));
|
|
134
|
+
} catch (err) {
|
|
135
|
+
reportError('addToCart', err);
|
|
136
|
+
}
|
|
137
|
+
};
|
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
|
/**
|
|
@@ -227,8 +245,17 @@ export interface AutocompleteConfigType extends AppConfig<AutocompleteConfigType
|
|
|
227
245
|
* `'input'`: always the input. At least 360 px wide; on a phone (below
|
|
228
246
|
* 480 px) a field spanning most of the screen exactly, else the screen's
|
|
229
247
|
* width.
|
|
230
|
-
|
|
231
|
-
|
|
248
|
+
*
|
|
249
|
+
* An object sets the field and, for the house panel (`dom: 'shadow'`), the
|
|
250
|
+
* page's content column it stays within: `{ field?: 'input' | string;
|
|
251
|
+
* column?: 'viewport' | string }`. `column`: a selector (the field's
|
|
252
|
+
* nearest ancestor matching it, else the first element matching it), or
|
|
253
|
+
* `'viewport'` (the screen less 24 px each side). By default the panel
|
|
254
|
+
* stays within the field's outermost ancestor that is at least 1000 px
|
|
255
|
+
* wide and narrower than the screen (a centred page container), else the
|
|
256
|
+
* screen.
|
|
257
|
+
*/
|
|
258
|
+
alignTo?: 'input' | string | { field?: 'input' | string; column?: 'viewport' | string };
|
|
232
259
|
/** _init.ts hooks, for sites whose own code reacts to their dropdown opening, closing or a pick. */
|
|
233
260
|
onOpen?: () => void;
|
|
234
261
|
/** Why it closed: a pick, Enter / Escape in the input, focus leaving, a press outside, a route change, … */
|