@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.
Files changed (41) hide show
  1. package/README.md +208 -76
  2. package/build/vite.mjs +9 -0
  3. package/chrome/block.mjs +8 -0
  4. package/chrome/run.mjs +10 -5
  5. package/docs/ACCESSIBILITY.md +9 -5
  6. package/docs/INTEGRATION-PERFORMANCE.md +5 -6
  7. package/package.json +1 -1
  8. package/parity/hold.mjs +1 -1
  9. package/src/analytics/chain.ts +137 -0
  10. package/src/analytics/collect.ts +64 -0
  11. package/src/analytics/datalayer.ts +67 -0
  12. package/src/analytics/from-ga4.ts +68 -0
  13. package/src/analytics/from-luma.ts +21 -0
  14. package/src/analytics/index.ts +24 -0
  15. package/src/analytics/outbox.ts +118 -0
  16. package/src/analytics/page.ts +46 -0
  17. package/src/analytics/pageview.ts +82 -0
  18. package/src/analytics/ref.ts +19 -0
  19. package/src/analytics/setup.ts +41 -0
  20. package/src/analytics/start.ts +53 -0
  21. package/src/analytics/triggers.ts +239 -0
  22. package/src/analytics/user.ts +12 -0
  23. package/src/analytics/wire.ts +67 -0
  24. package/src/api/token.ts +3 -2
  25. package/src/core/entries.ts +10 -6
  26. package/src/core/integration.ts +6 -0
  27. package/src/core/kept.ts +25 -0
  28. package/src/core/stash.ts +24 -4
  29. package/src/core/tiles.ts +137 -0
  30. package/src/main.ts +54 -4
  31. package/src/types.ts +29 -2
  32. package/src/widgets/autocomplete/index.ts +206 -31
  33. package/src/widgets/autocomplete/style-autocomplete.css +11 -5
  34. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
  35. package/src/widgets/listing/element.ts +212 -21
  36. package/src/widgets/listing/instant.ts +83 -23
  37. package/src/widgets/listing/stash.ts +31 -8
  38. package/src/widgets/listing/view.ts +12 -2
  39. package/src/widgets/recommender/index.ts +15 -4
  40. package/src/widgets/search/range.ts +40 -18
  41. 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 idle = () => (typeof requestIdleCallback === 'function' ? requestIdleCallback(warm, { timeout: 2000 }) : setTimeout(warm, 200));
157
- if (document.readyState === 'complete') idle();
158
- else addEventListener('load', idle, { once: true });
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
- alignTo?: 'input' | string;
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, … */