@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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// collect.pageView: a `pv` event per page (./page.ts), with the page's object
|
|
2
|
+
// when the chain reads one:
|
|
3
|
+
//
|
|
4
|
+
// collect.pageView.on(lumaPages()).read(fromLuma.pageView); // every page
|
|
5
|
+
// collect.pageView.on(lumaPages()).read(() => ref.product(window.sku)); // its reference, read at the page
|
|
6
|
+
// collect.pageView.on(dl('view_item')).timeout(1500).read(fromGa4.pageView);
|
|
7
|
+
// // every page, its reference from a trigger, waited for (default 1000 ms, then without)
|
|
8
|
+
//
|
|
9
|
+
// One pageView chain per setup: a second one is an error, not a second pageview.
|
|
10
|
+
|
|
11
|
+
import { reportError } from '../core/health';
|
|
12
|
+
import { checked, type Read, type Trigger } from './chain';
|
|
13
|
+
import { emit } from './outbox';
|
|
14
|
+
import { onPage, type Page } from './page';
|
|
15
|
+
import { whenShown } from './setup';
|
|
16
|
+
import { isRef } from './wire';
|
|
17
|
+
|
|
18
|
+
let chained = false;
|
|
19
|
+
const send = (p: Page, reference?: string | null) => emit({ type: 'pv', url: p.url, ...(p.referrer ? { referrer: p.referrer } : {}), ...(isRef(reference) ? { reference } : {}) });
|
|
20
|
+
|
|
21
|
+
export interface PageViewChain<C> {
|
|
22
|
+
/** lumaPages(): only the pages `f` says (the rest send none). Another trigger: only a context `f` says is the page's. */
|
|
23
|
+
when(f: (ctx: C) => boolean): PageViewChain<C>;
|
|
24
|
+
/** How long a page waits for its reference from the trigger (default 1000 ms), then goes without. Not for lumaPages(). */
|
|
25
|
+
timeout(ms: number): PageViewChain<C>;
|
|
26
|
+
/** The page's reference, read from the trigger's context (null: the page without one). */
|
|
27
|
+
read(r: Read<'pageView', C>): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const pageView = {
|
|
31
|
+
on<C>(t: Trigger<C>): PageViewChain<C> {
|
|
32
|
+
let when: ((ctx: C) => boolean) | undefined;
|
|
33
|
+
let ms = 1000;
|
|
34
|
+
const b: PageViewChain<C> = {
|
|
35
|
+
when(f) {
|
|
36
|
+
when = f;
|
|
37
|
+
return b;
|
|
38
|
+
},
|
|
39
|
+
timeout(n) {
|
|
40
|
+
ms = n;
|
|
41
|
+
return b;
|
|
42
|
+
},
|
|
43
|
+
read(r) {
|
|
44
|
+
checked('pageView', r);
|
|
45
|
+
if (chained) throw new Error('analytics: one collect.pageView chain per setup');
|
|
46
|
+
chained = true;
|
|
47
|
+
const ref = (ctx: C) => {
|
|
48
|
+
try {
|
|
49
|
+
return r(ctx);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
reportError('analytics pageView', e);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
// lumaPages(): the page is the context, sent at once.
|
|
56
|
+
if (t.key === 'pages') return void t.sub((p) => (!when || when(p)) && send(p as Page, ref(p)));
|
|
57
|
+
/** The pageview waiting for its reference, if any. */
|
|
58
|
+
let waiting: ((r?: string | null) => void) | null = null;
|
|
59
|
+
onPage((p) => {
|
|
60
|
+
waiting?.(); // the previous page's, still waiting: sent without one
|
|
61
|
+
const timer = setTimeout(() => waiting?.(), ms);
|
|
62
|
+
waiting = (x) => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
waiting = null;
|
|
65
|
+
send(p, x);
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
// After the first page waits (onPage's own whenShown runs first): what the
|
|
69
|
+
// trigger has already, the dataLayer's earlier pushes, completes it.
|
|
70
|
+
whenShown(() =>
|
|
71
|
+
t.sub((ctx) => {
|
|
72
|
+
// Not from another page (the last page's dataLayer, kept for us: `at`).
|
|
73
|
+
if (!waiting || typeof (ctx as { at?: unknown })?.at === 'number' || (when && !when(ctx))) return;
|
|
74
|
+
const x = ref(ctx);
|
|
75
|
+
if (x != null) waiting(x);
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
return b;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// References (docs.luigisbox.ai/analytics/references), `<type>/<field>:<value>`:
|
|
2
|
+
//
|
|
3
|
+
// ref.product('1234') // product/@id:product/1234 (the type's field, default @id)
|
|
4
|
+
// ref.product('ean', '4006…') // product/ean:4006…
|
|
5
|
+
// ref.product.by('id'); // the field ref.product(value) uses from now on
|
|
6
|
+
//
|
|
7
|
+
// Types: product, variant, category, brand.
|
|
8
|
+
|
|
9
|
+
import { ref as reference } from './wire';
|
|
10
|
+
|
|
11
|
+
const make = (type: string) => {
|
|
12
|
+
let field = '@id';
|
|
13
|
+
const f = (a: unknown, b?: unknown) => (b === undefined ? reference(type, field, a) : reference(type, String(a), b));
|
|
14
|
+
/** The catalog field `ref.<type>(value)` uses (default `@id`). */
|
|
15
|
+
f.by = (attribute: string) => void (field = attribute);
|
|
16
|
+
return f;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const ref = { product: make('product'), variant: make('variant'), category: make('category'), brand: make('brand') };
|