@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/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
  /**
@@ -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.v.render('recommender-box-default', { ...ctx.helpers, results: hits.map(ctx.result) });
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
- popView!.render('recommender-popup', { ...ctx.helpers, results: hits.map(ctx.result), close });
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.