@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
@@ -1,7 +1,7 @@
1
1
  # Integration performance: what to measure, what to optimize
2
2
 
3
3
  A guideline for integration work (an integration repository with Luma as the
4
- `@luigisbox/luma` package, or as the `luma/` submodule). Luma's own budgets are
4
+ `@luigisbox/luma` package). Luma's own budgets are
5
5
  held by its own test suites. An integration can still undo them with heavy tiles, a
6
6
  layout that moves, or a slow install. This page lists the numbers to watch,
7
7
  in priority order, and the levers an integration has.
@@ -187,10 +187,9 @@ slowest type sets the pace (usually products).
187
187
 
188
188
  ## How to measure, step by step
189
189
 
190
- 0. `yarn luma tooling` (with the `luma/` submodule, before the script exists:
191
- `node luma/build/tooling.mjs`):
192
- the integration has the one `luma` script and its storefront's host in
193
- `luma.config.mjs` (`--write` adds them); `yarn luma` lists the tools.
190
+ 0. `yarn luma tooling`: the integration has the one `luma` script and its
191
+ storefront's host in `luma.config.mjs` (`--write` adds them); `yarn luma`
192
+ lists the tools.
194
193
  1. `yarn luma footprint --rtt 100 --runs 5` in the integration repository, before
195
194
  and after the change. Compare the cold and warm `ready`, main thread, scripts,
196
195
  bytes, chain and API requests per scenario. Commit the JSON (`--json`) so
@@ -208,7 +207,7 @@ slowest type sets the pace (usually products).
208
207
  layout (facets and quick types above or beside) differs by width, and the
209
208
  first screen holds more tiles on a tall one.
210
209
 
211
- ## What the customer includes
210
+ ## What the site includes
212
211
 
213
212
  The snippet with the connection hints is in README.md ("What the customer
214
213
  includes").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luigisbox/luma",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/parity/hold.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Luma on a page as a customer installs it (README, "What the customer includes"): the
1
+ // Luma on a page as a customer installs it (README, "What the site includes"): the
2
2
  // generic early-hold snippet (snippet/early-hold.min.js) first in <head>,
3
3
  // then the build. `yarn luma parity` (ours in every mode: replay, --record,
4
4
  // --live, --dev) and `yarn luma a11y` put the build in the storefront's own page
@@ -0,0 +1,137 @@
1
+ // The builder: `collect.<event>.on(trigger).when(f).throttle(ms).once().read(r)`.
2
+ // A chain is registered as the setup file runs and listens once the page is
3
+ // shown. `.read()` turns the trigger's context into what the event sends: a
4
+ // function of the integration's, or a reader (./from-ga4.ts, ./from-luma.ts),
5
+ // which is a function tagged with the one event it reads for. Where Luma runs
6
+ // that service of the site's on the page (`owns`: our search page, our
7
+ // autocomplete, our recommender of that name), its chain does nothing: theirs
8
+ // is not on the page, ours is tracked by the API.
9
+
10
+ import { reportError } from '../core/health';
11
+ import { onPage } from './page';
12
+ import { origin } from './outbox';
13
+ import { env, whenShown } from './setup';
14
+
15
+ /** When a chain's `.read()` runs, and with what (./triggers.ts). */
16
+ export interface Trigger<C = any> {
17
+ key: string;
18
+ sub(h: (ctx: C) => void): void;
19
+ }
20
+
21
+ /** The events a setup collects (`collect.<event>`), as readers name them. */
22
+ export type EventName = 'pageView' | 'click' | 'addToCart' | 'purchase' | 'search' | 'autocomplete' | 'recommender';
23
+
24
+ /** One add-to-cart: what `collect.addToCart` sends (or a list of them). */
25
+ export interface AddToCart {
26
+ reference: string;
27
+ count?: number;
28
+ price?: number;
29
+ }
30
+ /** A purchase: what `collect.purchase` sends, once per `id`. */
31
+ export interface Purchase {
32
+ id?: string | number;
33
+ currency?: string;
34
+ items: { reference: string; title: string; count: number; total_price: number; was_discounted?: boolean }[];
35
+ }
36
+ /** A list of the site's own service (`collect.search`, `.autocomplete`, `.recommender(name)`). */
37
+ export interface List {
38
+ query?: { string: string };
39
+ items: { title?: string; type?: string; reference?: string; position?: number; price?: number; [k: string]: unknown }[];
40
+ filters?: Record<string, unknown>;
41
+ }
42
+ /** What `.read()` returns, per event (null or undefined: nothing sent). */
43
+ export interface Reads {
44
+ pageView: string | null | undefined;
45
+ click: string | { reference: string };
46
+ addToCart: AddToCart | AddToCart[];
47
+ purchase: Purchase;
48
+ search: List;
49
+ autocomplete: List;
50
+ recommender: List;
51
+ }
52
+
53
+ /**
54
+ * What `.read()` takes for event `E` on context `C`: a function of the
55
+ * setup's, or a reader of that event (`event`: a reader for another event is
56
+ * a type error, and an error as the setup runs).
57
+ */
58
+ export type Read<E extends EventName, C> = ((ctx: C) => Reads[E] | null | undefined) & { event?: E };
59
+
60
+ /** A reader: `f` for event `event` (its own function: readers may share `f`). Pure: one the setup does not name is left out of the build. */
61
+ export const reader = <E extends EventName, C>(event: E, f: (ctx: C) => Reads[E] | null | undefined) => Object.assign((ctx: C) => f(ctx), { event }) as Read<E, C> & { event: E };
62
+
63
+ /** `.read(r)` on event `name`: `r`, or an error when it is a reader of another event. */
64
+ export const checked = (name: string, r: { event?: string }) => {
65
+ const e = name.startsWith('recommender:') ? 'recommender' : name;
66
+ if (r.event && r.event !== e) throw new Error(`analytics: collect.${e}.read(): a reader of ${r.event}`);
67
+ };
68
+
69
+ export interface Chain<E extends EventName, C> {
70
+ /** Only when `f` says so. */
71
+ when(f: (ctx: C) => boolean): Chain<E, C>;
72
+ /** At most once per `ms` (the rest dropped). */
73
+ throttle(ms: number): Chain<E, C>;
74
+ /** Once per page. */
75
+ once(): Chain<E, C>;
76
+ /** What the event sends, read from the trigger's context (null: nothing). */
77
+ read(r: Read<E, C>): void;
78
+ }
79
+ export interface Surface<E extends EventName> {
80
+ on<C>(t: Trigger<C>): Chain<E, C>;
81
+ }
82
+
83
+ /** An event of the site's: `deliver` sends what a chain's `.read()` made. */
84
+ export const surface = <E extends EventName>(name: string, deliver: (p: Reads[E]) => void): Surface<E> => ({
85
+ on<C>(trigger: Trigger<C>) {
86
+ let when: ((ctx: C) => boolean) | undefined;
87
+ let once = false;
88
+ let every = 0;
89
+ let last = 0;
90
+ const b: Chain<E, C> = {
91
+ when(f) {
92
+ when = f;
93
+ return b;
94
+ },
95
+ throttle(ms) {
96
+ every = ms;
97
+ return b;
98
+ },
99
+ once() {
100
+ once = true;
101
+ return b;
102
+ },
103
+ read(r) {
104
+ checked(name, r);
105
+ let fired = false;
106
+ if (once) onPage(() => (fired = false));
107
+ whenShown(() =>
108
+ trigger.sub((ctx) => {
109
+ if (fired || env.owns(name)) return;
110
+ if (every) {
111
+ if (Date.now() - last < every) return;
112
+ last = Date.now();
113
+ }
114
+ try {
115
+ if (when && !when(ctx)) return;
116
+ const p = r(ctx);
117
+ if (p == null) return;
118
+ fired = once;
119
+ // A context from earlier (`at`: queued, or kept by the last page) is sent as of then and there.
120
+ const c = ctx as { url?: string; at?: number };
121
+ origin.url = typeof c?.at === 'number' ? c.url : undefined;
122
+ origin.at = typeof c?.at === 'number' ? c.at : undefined;
123
+ try {
124
+ deliver(p);
125
+ } finally {
126
+ origin.url = origin.at = undefined;
127
+ }
128
+ } catch (e) {
129
+ reportError(`analytics ${name}`, e);
130
+ }
131
+ }),
132
+ );
133
+ },
134
+ };
135
+ return b;
136
+ },
137
+ });
@@ -0,0 +1,64 @@
1
+ // The events (`collect.<event>` in _analytics.ts): each its own export, so a
2
+ // setup brings only those it uses. What each sends:
3
+ // - pageView: `pv` per page (./pageview.ts);
4
+ // - search, autocomplete, recommender(name): a `list` event of the site's own
5
+ // service (`{ query?, items, filters? }`; ours the API records itself);
6
+ // - click: an `interaction` click (a reference, or `{ reference }`);
7
+ // - addToCart: `interaction` add_to_cart per item (`{ reference, count?, price? }` or a list);
8
+ // - purchase: a `transaction` (`{ id?, currency?, items }`), once per id.
9
+
10
+ import { emit } from './outbox';
11
+ import { state } from './setup';
12
+ import { surface, type AddToCart, type List, type Purchase } from './chain';
13
+ import { isRef } from './wire';
14
+
15
+ export { pageView } from './pageview';
16
+
17
+ /** On an add-to-cart our tiles made (fromLuma.addToCart): the site's own push its cart then makes is the same one. */
18
+ export const OURS = '__lbxOurs';
19
+
20
+ const list = (name: string) => (p: List) => Array.isArray(p?.items) && emit({ type: 'list', list_name: name, listing_ab_test: false, ...p });
21
+
22
+ /** The site's own search results: a `list` event. */
23
+ export const search = /* @__PURE__ */ surface<'search'>('search', /* @__PURE__ */ list('Search'));
24
+ /** The site's own suggestions: a `list` event. */
25
+ export const autocomplete = /* @__PURE__ */ surface<'autocomplete'>('autocomplete', /* @__PURE__ */ list('Autocomplete'));
26
+ /** The site's own recommender `name` (silent where ours of that name runs): a `list` event. */
27
+ export const recommender = (name: string) => surface<'recommender'>(`recommender:${name}`, list('Recommendation'));
28
+
29
+ /** A product click: the reference (or `{ reference }`). */
30
+ export const click = /* @__PURE__ */ surface<'click'>('click', (p) => {
31
+ const reference = typeof p === 'string' ? p : p?.reference;
32
+ if (isRef(reference)) emit({ type: 'interaction', interaction_type: 'click', listing_ab_test: false, reference });
33
+ });
34
+
35
+ const currency = () => state.g?.currency;
36
+ /** When the last add-to-cart on our tiles was: the site's own push its cart then makes is the same one. */
37
+ let ourCart = 0;
38
+ const OUR_CART_MS = 10_000;
39
+ /** Add-to-cart: `{ reference, count?, price? }` or a list of them. */
40
+ export const addToCart = /* @__PURE__ */ surface<'addToCart'>('addToCart', (p) => {
41
+ const all: (AddToCart & { [OURS]?: true })[] = Array.isArray(p) ? p : [p];
42
+ if (all.some((x) => x?.[OURS])) ourCart = Date.now();
43
+ else if (Date.now() - ourCart < OUR_CART_MS) return void (ourCart = 0);
44
+ for (const x of all) {
45
+ if (!isRef(x?.reference)) continue;
46
+ emit({ type: 'interaction', interaction_type: 'add_to_cart', listing_ab_test: false, reference: x.reference, ...(x.count ? { count: x.count } : {}), ...(x.price != null && x.price >= 0 ? { price: x.price } : {}), ...(currency() ? { currency: currency() } : {}) });
47
+ }
48
+ });
49
+
50
+ const TX = 'lbx:tx';
51
+ const seenTx = (id: string) => {
52
+ try {
53
+ const ids: string[] = JSON.parse(localStorage.getItem(TX) ?? '[]');
54
+ if (ids.includes(id)) return true;
55
+ localStorage.setItem(TX, JSON.stringify([...ids, id].slice(-20)));
56
+ } catch {}
57
+ return false;
58
+ };
59
+ /** A purchase: `{ id?, currency?, items: [{ reference, title, count, total_price }] }`, once per id (a reloaded thank-you page keeps its push). */
60
+ export const purchase = /* @__PURE__ */ surface<'purchase'>('purchase', (p: Purchase) => {
61
+ if (!Array.isArray(p.items) || !p.items.length || (p.id != null && seenTx(String(p.id)))) return;
62
+ const cur = p.currency ?? currency();
63
+ emit({ type: 'transaction', listing_ab_test: false, items: p.items, ...(cur ? { currency: cur } : {}) });
64
+ });
@@ -0,0 +1,67 @@
1
+ // The site's dataLayer (`_global.dataLayer`, default `dataLayer`), for the
2
+ // `dl()` trigger: its push wrapped once (any push already there, GTM's,
3
+ // chained), and what was pushed before we were here read too: on this page,
4
+ // and on pages left before analytics ran there (the entry keeps their pushes,
5
+ // `lbx:dl`: replayed first, as of their page and time).
6
+
7
+ import { reportError } from '../core/health';
8
+ import { session } from '../core/storage';
9
+ import { KEPT } from '../core/kept';
10
+ import { env, state } from './setup';
11
+ import { readDl, type DlEntry } from './wire';
12
+
13
+ const handlers = new Set<(e: DlEntry) => void>();
14
+ const layer = (): any[] => {
15
+ const w = window as any;
16
+ const name = state.g?.dataLayer ?? 'dataLayer';
17
+ return Array.isArray(w[name]) ? w[name] : (w[name] = []);
18
+ };
19
+ /** Pushes of pages left before analytics ran (main.ts): read once, then gone from storage. */
20
+ interface Kept {
21
+ /** The page. */
22
+ u: string;
23
+ /** When it was left. */
24
+ t: number;
25
+ /** Its pushes. */
26
+ e: unknown[];
27
+ }
28
+ let kept: Kept[] | null = null;
29
+ const heard = (x: any) => {
30
+ const e = readDl(x);
31
+ if (e)
32
+ for (const h of handlers)
33
+ try {
34
+ h(e);
35
+ } catch (err) {
36
+ reportError(`analytics ${e.event}`, err);
37
+ }
38
+ };
39
+
40
+ /** The site's dataLayer event `name` (object or gtag form) from now on, and what was pushed before. */
41
+ export const onDl = (name: string, h: (e: DlEntry) => void) => {
42
+ const dl = layer();
43
+ if (!(dl as any).__lbx) {
44
+ const prev = Object.prototype.hasOwnProperty.call(dl, 'push') ? dl.push : null;
45
+ dl.push = function (...items: any[]) {
46
+ const n = prev ? prev.apply(dl, items) : Array.prototype.push.apply(dl, items);
47
+ for (const x of items) heard(x);
48
+ return n;
49
+ };
50
+ (dl as any).__lbx = true;
51
+ }
52
+ const f = (e: DlEntry) => e.event === name && h(e);
53
+ handlers.add(f);
54
+ if (!kept) {
55
+ kept = session.get<Kept[]>(KEPT) ?? [];
56
+ session.set(KEPT, undefined);
57
+ }
58
+ for (const k of kept)
59
+ for (const x of k.e) {
60
+ const e = readDl(x);
61
+ if (e) f({ ...e, url: k.u, at: k.t });
62
+ }
63
+ for (const x of dl.slice(env.kept())) {
64
+ const e = readDl(x);
65
+ if (e) f(e);
66
+ }
67
+ };
@@ -0,0 +1,68 @@
1
+ // Readers of the site's GA4 ecommerce (`.read(fromGa4.<event>)`), one export
2
+ // each: a setup brings only those it names.
3
+ //
4
+ // collect.addToCart.on(dl('add_to_cart')).read(fromGa4.addToCart);
5
+ // collect.purchase.on(dl('purchase')).read(fromGa4.purchase);
6
+ // collect.click.on(dl('select_item')).read(fromGa4.click);
7
+ // collect.pageView.on(dl('view_item')).read(fromGa4.pageView);
8
+ // collect.search.on(dl('view_item_list')).read(fromGa4.search);
9
+ //
10
+ // The ecommerce is the push's `ecommerce` (GTM; gtag's parameters, as dl()
11
+ // hands them), else the push itself when its `items` are on it. Items are
12
+ // referenced by `ref.product(item_id)` (ref.product.by() for the field).
13
+
14
+ import { reader, type AddToCart, type List } from './chain';
15
+ import { ref } from './ref';
16
+
17
+ interface Ctx {
18
+ data?: Record<string, any>;
19
+ ecommerce?: Record<string, any> | null;
20
+ }
21
+ const ecommerceOf = (ctx: Ctx): Record<string, any> => (ctx.ecommerce?.items ? ctx.ecommerce : ctx.data?.items ? ctx.data : (ctx.ecommerce ?? ctx.data ?? {}));
22
+ /** Its items with an id (one without: no reference, not sent). */
23
+ const itemsOf = (ctx: Ctx): Record<string, any>[] => {
24
+ const items = ecommerceOf(ctx).items;
25
+ return Array.isArray(items) ? items.filter((i) => i?.item_id != null) : [];
26
+ };
27
+ const num = (x: unknown) => {
28
+ const n = typeof x === 'string' ? Number(x.replace(',', '.')) : x;
29
+ return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
30
+ };
31
+ const count = (x: unknown) => Math.max(1, Math.round(num(x) ?? 1));
32
+ const product = (i: Record<string, any>) => ref.product(String(i.item_id));
33
+ const first = (ctx: Ctx) => {
34
+ const [i] = itemsOf(ctx);
35
+ return i ? product(i) : null;
36
+ };
37
+ const list = (ctx: Ctx): List | null => {
38
+ const e = ecommerceOf(ctx);
39
+ const items = itemsOf(ctx).map((i, n) => ({ title: String(i.item_name ?? i.item_id), type: 'product', reference: product(i), position: (num(i.index) ?? n) + 1, ...(num(i.price) != null ? { price: num(i.price) } : {}) }));
40
+ return items.length ? { ...(e.search_term ? { query: { string: String(e.search_term) } } : {}), items } : null;
41
+ };
42
+
43
+ /** add_to_cart: one add-to-cart per item, its quantity and unit price. */
44
+ export const addToCart = /* @__PURE__ */ reader('addToCart', (ctx: Ctx) =>
45
+ itemsOf(ctx).map((i): AddToCart => {
46
+ const price = num(i.price);
47
+ return { reference: product(i), count: count(i.quantity), ...(price! >= 0 ? { price } : {}) };
48
+ }),
49
+ );
50
+ /** purchase: the order, its lines' totals (not unit prices), discounted lines, the transaction id (sent once). */
51
+ export const purchase = /* @__PURE__ */ reader('purchase', (ctx: Ctx) => {
52
+ const e = ecommerceOf(ctx);
53
+ const items = itemsOf(ctx).map((i) => {
54
+ const n = count(i.quantity);
55
+ return { reference: product(i), title: String(i.item_name ?? i.item_id), count: n, total_price: Math.round((num(i.price) ?? 0) * n * 100) / 100, ...(num(i.discount)! > 0 ? { was_discounted: true } : {}) };
56
+ });
57
+ return items.length ? { id: e.transaction_id, currency: e.currency, items } : null;
58
+ });
59
+ /** select_item: the product clicked. */
60
+ export const click = /* @__PURE__ */ reader('click', first);
61
+ /** view_item: the page's product. */
62
+ export const pageView = /* @__PURE__ */ reader('pageView', first);
63
+ /** view_item_list of their search: its items, the query from search_term. */
64
+ export const search = /* @__PURE__ */ reader('search', list);
65
+ /** view_item_list of their suggestions. */
66
+ export const autocomplete = /* @__PURE__ */ reader('autocomplete', list);
67
+ /** view_item_list of their recommender. */
68
+ export const recommender = /* @__PURE__ */ reader('recommender', list);
@@ -0,0 +1,21 @@
1
+ // Readers of Luma's own events (`.read(fromLuma.<event>)`), one export each:
2
+ // what Luma saw is what the event sends.
3
+ //
4
+ // collect.pageView.on(lumaPages()).read(fromLuma.pageView);
5
+ // collect.click.on(lumaClicks()).read(fromLuma.click);
6
+ // collect.addToCart.on(lumaCarts()).read(fromLuma.addToCart);
7
+ //
8
+ // Our tiles' references are their results' `@id`s (every result has one),
9
+ // whatever ref.product.by() says for the site's own data.
10
+
11
+ import type { TileEvent } from '../core/tiles';
12
+ import { reader } from './chain';
13
+ import { OURS } from './collect';
14
+ import type { Page } from './page';
15
+
16
+ /** A page: no reference of its own. */
17
+ export const pageView = /* @__PURE__ */ reader('pageView', (_: Page) => null);
18
+ /** A click on one of our tiles: the product clicked. */
19
+ export const click = /* @__PURE__ */ reader('click', (t: TileEvent) => t.reference);
20
+ /** `[data-lbx-cart]` on one of our tiles: the product, its quantity and price (the site's own push its cart then makes is the same one). */
21
+ export const addToCart = /* @__PURE__ */ reader('addToCart', (t: TileEvent) => ({ reference: t.reference, count: t.count ?? 1, ...(t.price != null ? { price: t.price } : {}), [OURS]: true as const }));
@@ -0,0 +1,24 @@
1
+ // The analytics API of an integration's `_analytics.ts` (README "Analytics").
2
+ // Every event sent is a `collect.<event>` chain, nothing else is:
3
+ //
4
+ // import { collect, dl, fromGa4, fromLuma, lumaPages, ref } from '@/analytics';
5
+ // ref.product.by('sku');
6
+ // collect.pageView.on(lumaPages()).read(fromLuma.pageView);
7
+ // collect.addToCart.on(dl('add_to_cart')).read(fromGa4.addToCart);
8
+ // collect.purchase.on(dl('purchase')).read(fromGa4.purchase);
9
+ //
10
+ // One chunk, lbx-analytics, with the setup in it: every event, trigger and
11
+ // reader is its own export (collect, fromGa4 and fromLuma are namespaces:
12
+ // `fromGa4.purchase` is static, the rest of fromGa4 left out), so the chunk
13
+ // holds what the setup names (yarn analytics:weight measures each).
14
+
15
+ export * as collect from './collect';
16
+ export * as fromGa4 from './from-ga4';
17
+ export * as fromLuma from './from-luma';
18
+ export { user } from './user';
19
+ export { ref } from './ref';
20
+ export { dl, domClick, lumaCarts, lumaClicks, lumaPages, manual, mutationOf, urlChange, xhrMatching } from './triggers';
21
+ export type { DlContext, DomClickContext, ManualContext, MutationContext, UrlChangeContext, XhrContext } from './triggers';
22
+ export type { AddToCart, List, Purchase, Read, Trigger } from './chain';
23
+ export type { Page } from './page';
24
+ export type { TileEvent } from '../core/tiles';
@@ -0,0 +1,118 @@
1
+ // Delivery: every event goes into an outbox in sessionStorage first (in the
2
+ // task of the click), then one keepalive POST each to the Luigi's Box AI
3
+ // events API (POST /analytics/v1/events), so a click that leaves the page is
4
+ // not lost: what did not get out is sent by the next page. The API dedupes a
5
+ // re-sent event by its event_id. Consent is the loader's: once Luma runs,
6
+ // events are sent (consent_granted: true).
7
+
8
+ import { mark, reportError } from '../core/health';
9
+ import { session } from '../core/storage';
10
+ import { call, stamps, state } from './setup';
11
+ import { eventUrl } from './wire';
12
+
13
+ const BOX = 'lbx:ev';
14
+ const MAX_BOX = 50;
15
+ const MAX_AGE_MS = 60 * 60e3;
16
+ interface Out {
17
+ b: Record<string, any>;
18
+ /** Tries so far. */
19
+ n: number;
20
+ at: number;
21
+ /** Not before (ms epoch): a retry's. */
22
+ t?: number;
23
+ }
24
+ const box = () => session.get<Out[]>(BOX) ?? [];
25
+ const save = (o: Out[]) => session.set(BOX, o.length ? o.slice(-MAX_BOX) : undefined);
26
+
27
+ /** Where and when what is being sent happened, when not here and now (./chain.ts: a queued tile event, a dataLayer push the last page kept). */
28
+ export const origin: { url?: string; at?: number } = {};
29
+
30
+ /** One event: into the outbox now, sent at once (a prerendered page's once shown). */
31
+ export const emit = (b: Record<string, any>) => {
32
+ const o = box();
33
+ o.push({ b: { event_id: crypto.randomUUID(), event_timestamp: Math.floor((origin.at ?? Date.now()) / 1000), url: eventUrl(origin.url ?? location.href), ...b }, n: 0, at: Date.now() });
34
+ save(o);
35
+ mark(`analytics: ${b.type}${b.interaction_type ? ` ${b.interaction_type}` : ''}`);
36
+ void flush();
37
+ };
38
+
39
+ let token: Promise<typeof import('../api/token')> | null = null;
40
+ /** Events being sent; `posted`: their keepalive request is out (it outlives the page). */
41
+ const sending = new Set<string>();
42
+ const posted = new Set<string>();
43
+ const SEND_MS = 8000;
44
+ const RETRY_MS = 2000;
45
+ let later = 0;
46
+ /** Send what the outbox holds and is due: each event at once, on its own. */
47
+ export const flush = async () => {
48
+ const g = state.g;
49
+ // A prerendered page sends nothing until it is shown (./start.ts flushes then).
50
+ if (!g || (document as any).prerendering) return;
51
+ const now = Date.now();
52
+ const o = box().filter((x) => now - x.at < MAX_AGE_MS && !sending.has(x.b.event_id));
53
+ const due = o.filter((x) => (x.t ?? 0) <= now);
54
+ // A retry waiting (this page's, or the last page's): its turn.
55
+ const next = Math.min(...o.map((x) => x.t ?? Infinity));
56
+ if (next > now && next < Infinity) {
57
+ clearTimeout(later);
58
+ later = window.setTimeout(flush, next - now + 10);
59
+ }
60
+ if (!due.length) return;
61
+ for (const x of due) sending.add(x.b.event_id);
62
+ let send: (x: Out) => Promise<number>;
63
+ try {
64
+ const t = await (token ??= import('../api/token'));
65
+ const tk = t.tokensFor(g);
66
+ const bearer = await tk.get();
67
+ const headers = { Authorization: `Bearer ${bearer}`, 'X-Lbx-Visitor-Id': t.visitorId(), 'Content-Type': 'application/json' };
68
+ const uid = stamps.user ? call(stamps.user, 'analytics user') : null;
69
+ send = async (x) => {
70
+ const c = new AbortController();
71
+ const timer = setTimeout(() => c.abort(), SEND_MS);
72
+ try {
73
+ const body = { channel_id: g.channelId, consent_granted: true, ...(uid != null && uid !== '' ? { user_id: String(uid) } : {}), ...x.b };
74
+ const p = fetch(`${tk.API}/analytics/v1/events`, { method: 'POST', headers, body: JSON.stringify(body), keepalive: true, signal: c.signal });
75
+ posted.add(x.b.event_id);
76
+ const status = (await p).status;
77
+ if (status === 401 && tk.current() === bearer) tk.clear();
78
+ return status;
79
+ } catch {
80
+ return 0;
81
+ } finally {
82
+ clearTimeout(timer);
83
+ }
84
+ };
85
+ } catch (e) {
86
+ // No token (a failed mint backs off by itself), its chunk did not load: the next event or page tries again.
87
+ token = null;
88
+ for (const x of due) sending.delete(x.b.event_id);
89
+ return mark(`analytics: not sent (${e instanceof Error ? e.message : e})`);
90
+ }
91
+ await Promise.all(due.map(async (x) => settle(x, await send(x))));
92
+ };
93
+
94
+ /** An event's answer: out of the outbox (taken, or refused for good), or tried once more later. */
95
+ const settle = (x: Out, status: number) => {
96
+ const id = x.b.event_id;
97
+ sending.delete(id);
98
+ posted.delete(id);
99
+ const o = box();
100
+ const at = o.findIndex((y) => y.b.event_id === id);
101
+ if (at < 0) return;
102
+ const refused = status >= 400 && status < 500 && status !== 401 && status !== 408 && status !== 429;
103
+ if (refused) reportError('analytics', `${x.b.type} refused: HTTP ${status}`);
104
+ if ((status >= 200 && status < 300) || refused || x.n + 1 >= 2) o.splice(at, 1);
105
+ else {
106
+ // Once more (the API's advice: one retry, the same event_id), later.
107
+ o[at].n = x.n + 1;
108
+ o[at].t = Date.now() + RETRY_MS;
109
+ }
110
+ save(o);
111
+ if (o[at]?.b.event_id === id) void flush(); // schedules the retry
112
+ };
113
+
114
+ // The page goes (a click on a product): what is posted already travels on its
115
+ // own (keepalive); what is not stays for the next page.
116
+ addEventListener('pagehide', () => {
117
+ if (posted.size) save(box().filter((x) => !posted.has(x.b.event_id)));
118
+ });
@@ -0,0 +1,46 @@
1
+ // A page, as analytics counts them: the first once shown (a prerendered page
2
+ // at activation), an SPA's next page, a restore from the back/forward cache.
3
+ // Not our own filters or sort: one listing visit is one page (the entry's
4
+ // `visit`), nor tracking params or the hash (wire.ts pageOf). For pageviews
5
+ // (./pageview.ts) and `.once()` (per page, ./chain.ts).
6
+
7
+ import { env, whenShown } from './setup';
8
+ import { eventUrl, pageOf } from './wire';
9
+
10
+ export interface Page {
11
+ /** The page's URL as events carry it (tracking params stripped). */
12
+ url: string;
13
+ /** The page before it (an SPA's), else document.referrer. */
14
+ referrer: string;
15
+ }
16
+ const subs: ((p: Page) => void)[] = [];
17
+ let current: Page | null = null;
18
+ let key = '';
19
+ let lastHref = '';
20
+ const check = () => {
21
+ const k = pageOf(location.href, env.visit);
22
+ if (k === key) return;
23
+ key = k;
24
+ const referrer = lastHref || document.referrer;
25
+ lastHref = location.href;
26
+ current = { url: eventUrl(location.href), referrer: referrer && eventUrl(referrer) };
27
+ for (const f of subs) f(current);
28
+ };
29
+ let started = false;
30
+ /** `f` on each new page: the current one at once (once shown), then the next ones. */
31
+ export const onPage = (f: (p: Page) => void) => {
32
+ subs.push(f);
33
+ if (current) f(current);
34
+ if (started) return;
35
+ started = true;
36
+ whenShown(() => {
37
+ check();
38
+ env.onUrlChange(check);
39
+ // A page the browser restores from its back/forward cache is viewed again.
40
+ addEventListener('pageshow', (e) => {
41
+ if (!e.persisted) return;
42
+ key = '';
43
+ check();
44
+ });
45
+ });
46
+ };