@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,41 @@
|
|
|
1
|
+
// The page's analytics state: the global config, what the integration's
|
|
2
|
+
// `_analytics.ts` reads for every event (user.identify), what only the entry
|
|
3
|
+
// knows (env), and the moment the page is shown (a prerendered page:
|
|
4
|
+
// once activated), when chains start to listen. Every other module reads it
|
|
5
|
+
// here; none runs anything at its top level, so what a setup does not import
|
|
6
|
+
// is left out of the build.
|
|
7
|
+
|
|
8
|
+
import { reportError } from '../core/health';
|
|
9
|
+
import type { GlobalConfigType } from '../types';
|
|
10
|
+
|
|
11
|
+
export const state: { g: GlobalConfigType | null } = { g: null };
|
|
12
|
+
/** Read on every event (./user.ts). */
|
|
13
|
+
export const stamps: { user: (() => string | number | null | undefined) | null } = { user: null };
|
|
14
|
+
|
|
15
|
+
/** A hook of the integration's: its value, or null when it threw (reported). */
|
|
16
|
+
export const call = <T>(f: () => T, where: string): T | null => {
|
|
17
|
+
try {
|
|
18
|
+
return f() ?? null;
|
|
19
|
+
} catch (e) {
|
|
20
|
+
reportError(where, e);
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** What only the entry knows: which of the site's services Luma runs here, URL changes, one listing visit. */
|
|
26
|
+
export const env: {
|
|
27
|
+
owns: (surface: string) => boolean;
|
|
28
|
+
onUrlChange: (l: (u: URL) => void) => void;
|
|
29
|
+
visit: (u: URL) => string | null;
|
|
30
|
+
/** This page's dataLayer pushes the entry kept already (left, then restored from the back/forward cache): not read again. */
|
|
31
|
+
kept: () => number;
|
|
32
|
+
} = { owns: () => false, onUrlChange: () => {}, visit: () => null, kept: () => 0 };
|
|
33
|
+
|
|
34
|
+
const waiting: (() => void)[] = [];
|
|
35
|
+
let shown = false;
|
|
36
|
+
/** Now if the page is shown, else once it is (a chain registered as the setup runs listens then). */
|
|
37
|
+
export const whenShown = (f: () => void) => (shown ? f() : void waiting.push(f));
|
|
38
|
+
export const pageShown = () => {
|
|
39
|
+
shown = true;
|
|
40
|
+
for (const f of waiting.splice(0)) f();
|
|
41
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Analytics on a page (lbx-analytics: the entry loads it once the page has
|
|
2
|
+
// loaded, or with the first widget, where the integration has an
|
|
3
|
+
// `_analytics.ts`; README "Analytics"). The setup is in this chunk: its chains
|
|
4
|
+
// (./collect.ts, ./triggers.ts) register as it runs, and what it does not
|
|
5
|
+
// import is not built in. Nothing is sent that a chain does not say.
|
|
6
|
+
//
|
|
7
|
+
// To the Luigi's Box AI events API (POST /analytics/v1/events, ./outbox.ts):
|
|
8
|
+
// `pv`, `interaction` (click, add_to_cart), `transaction`, and `list` for a
|
|
9
|
+
// service of the site's own (ours the API records itself). Never anything
|
|
10
|
+
// speculative: a prerendered page counts and sends once shown.
|
|
11
|
+
|
|
12
|
+
import { reportError } from '../core/health';
|
|
13
|
+
import type { LumaConfigType } from '../types';
|
|
14
|
+
import { flush } from './outbox';
|
|
15
|
+
import { env, pageShown, state } from './setup';
|
|
16
|
+
|
|
17
|
+
export interface StartOptions {
|
|
18
|
+
config: LumaConfigType;
|
|
19
|
+
/** The integration's `_analytics.ts` files (lazy; in this chunk), `_global`'s first. */
|
|
20
|
+
files: Record<string, () => Promise<any>>;
|
|
21
|
+
/** Whether Luma runs this service of the site's on this page (`search`, `autocomplete`, `recommender:<name>`). */
|
|
22
|
+
owns: (surface: string) => boolean;
|
|
23
|
+
/** One listing visit (a category, a query) is one page, whatever its filters write. */
|
|
24
|
+
visit: (u: URL) => string | null;
|
|
25
|
+
onUrlChange: (l: (u: URL) => void) => void;
|
|
26
|
+
/** This page's dataLayer pushes the entry kept for the next page already (core/kept.ts). */
|
|
27
|
+
kept: () => number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let started = false;
|
|
31
|
+
/** The page's analytics (the entry, once per page). */
|
|
32
|
+
export const start = async (o: StartOptions) => {
|
|
33
|
+
if (started) return;
|
|
34
|
+
started = true;
|
|
35
|
+
state.g = o.config._global;
|
|
36
|
+
env.owns = o.owns;
|
|
37
|
+
env.visit = o.visit;
|
|
38
|
+
env.onUrlChange = o.onUrlChange;
|
|
39
|
+
env.kept = o.kept;
|
|
40
|
+
// The setup (`_global` first). Its chains register as it runs.
|
|
41
|
+
for (const [p, f] of Object.entries(o.files).sort(([a], [b]) => (a.includes('/_global/') ? -1 : b.includes('/_global/') ? 1 : 0)))
|
|
42
|
+
try {
|
|
43
|
+
await f();
|
|
44
|
+
} catch (e) {
|
|
45
|
+
reportError(`analytics ${p}`, e);
|
|
46
|
+
}
|
|
47
|
+
const shown = () => {
|
|
48
|
+
pageShown();
|
|
49
|
+
void flush();
|
|
50
|
+
};
|
|
51
|
+
if ((document as any).prerendering) document.addEventListener('prerenderingchange', shown, { once: true });
|
|
52
|
+
else shown();
|
|
53
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Triggers of the builder (./chain.ts): when a chain's `.read()` runs, with
|
|
2
|
+
// what. Luma's own (lumaPages, lumaClicks, lumaCarts) and the site's (dl,
|
|
3
|
+
// domClick, urlChange, manual, mutationOf, xhrMatching). Each is its own export: a setup brings only the ones it imports (the
|
|
4
|
+
// XMLHttpRequest and fetch wrapper, the mutation observer, the click
|
|
5
|
+
// listeners only where a chain uses them).
|
|
6
|
+
|
|
7
|
+
import { reportError } from '../core/health';
|
|
8
|
+
import { session } from '../core/storage';
|
|
9
|
+
import { TILES, type TileEvent } from '../core/tiles';
|
|
10
|
+
import type { Trigger } from './chain';
|
|
11
|
+
import { onDl } from './datalayer';
|
|
12
|
+
import { onPage, type Page } from './page';
|
|
13
|
+
import { env } from './setup';
|
|
14
|
+
|
|
15
|
+
export interface DlContext {
|
|
16
|
+
event: string;
|
|
17
|
+
/** The same as `event`. */
|
|
18
|
+
eventType: string;
|
|
19
|
+
/** The entry as pushed (gtag's: `{ event, ...params }`). */
|
|
20
|
+
data: Record<string, any>;
|
|
21
|
+
/** GA4's ecommerce object (gtag's: its parameters), or null. */
|
|
22
|
+
ecommerce: Record<string, any> | null;
|
|
23
|
+
/** A push of a page left before analytics ran there: that page, and when (the event is sent as of them). */
|
|
24
|
+
url?: string;
|
|
25
|
+
at?: number;
|
|
26
|
+
}
|
|
27
|
+
/** The site's dataLayer event `name` (object or gtag form), the ones pushed before Luma loaded too. */
|
|
28
|
+
export const dl = (name: string): Trigger<DlContext> => ({
|
|
29
|
+
key: `dl:${name}`,
|
|
30
|
+
sub: (h) => onDl(name, (e) => h({ ...e, eventType: e.event })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export interface DomClickContext {
|
|
34
|
+
/** The element matching `selector` (the click's target or an ancestor). */
|
|
35
|
+
element: Element;
|
|
36
|
+
event: MouseEvent;
|
|
37
|
+
}
|
|
38
|
+
/** A click (or a middle click) on an element of the site's matching `selector`: never on ours (`lumaClicks()`). */
|
|
39
|
+
export const domClick = (selector: string): Trigger<DomClickContext> => ({
|
|
40
|
+
key: `domClick:${selector}`,
|
|
41
|
+
sub: (h) => {
|
|
42
|
+
const on = (e: Event) => {
|
|
43
|
+
const me = e as MouseEvent;
|
|
44
|
+
if (e.type === 'auxclick' && me.button !== 1) return;
|
|
45
|
+
const t = e.composedPath()[0];
|
|
46
|
+
const el = t instanceof Element ? t.closest(selector) : null;
|
|
47
|
+
if (el && !el.closest('[data-lbx]')) h({ element: el, event: me });
|
|
48
|
+
};
|
|
49
|
+
addEventListener('click', on, true);
|
|
50
|
+
addEventListener('auxclick', on, true);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export interface UrlChangeContext {
|
|
55
|
+
url: string;
|
|
56
|
+
previousUrl: string;
|
|
57
|
+
}
|
|
58
|
+
/** The page's URL changed (an SPA navigation, ours too). */
|
|
59
|
+
export const urlChange = (): Trigger<UrlChangeContext> => ({
|
|
60
|
+
key: 'url',
|
|
61
|
+
sub: (h) => {
|
|
62
|
+
let previousUrl = location.href;
|
|
63
|
+
env.onUrlChange((u) => {
|
|
64
|
+
h({ url: u.href, previousUrl });
|
|
65
|
+
previousUrl = u.href;
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Luma's pages, as analytics counts them (./page.ts): the first once shown, an
|
|
72
|
+
* SPA's next page, a back/forward cache restore; not our own filters or sort.
|
|
73
|
+
* The context is the page, `{ url, referrer }`: `collect.pageView.on(lumaPages())`.
|
|
74
|
+
*/
|
|
75
|
+
export const lumaPages = (): Trigger<Page> => ({ key: 'pages', sub: (h) => onPage(h) });
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Luma's own tiles (core/tiles.ts): a product click on them (`lumaClicks()`,
|
|
79
|
+
* the autocomplete's picks too), an add-to-cart by their `[data-lbx-cart]`
|
|
80
|
+
* (`lumaCarts()`). The context is a TileEvent, `{ reference, result, index,
|
|
81
|
+
* list, count?, price?, url, at }`, read by fromLuma.click / .addToCart.
|
|
82
|
+
* Every one, whenever it happened: queued before analytics listened (on this
|
|
83
|
+
* page or the last), claimed now, sent as of its page and time.
|
|
84
|
+
*/
|
|
85
|
+
const tiles = (kind: TileEvent['kind']): Trigger<TileEvent> => ({
|
|
86
|
+
key: `tiles:${kind}`,
|
|
87
|
+
sub: (h) => {
|
|
88
|
+
// Claimed from the queue (core/tiles.ts): each once, whenever analytics listens.
|
|
89
|
+
const claim = (id: string) => {
|
|
90
|
+
const q = session.get<TileEvent[]>(TILES) ?? [];
|
|
91
|
+
const i = q.findIndex((x) => x.id === id);
|
|
92
|
+
if (i < 0) return false;
|
|
93
|
+
q.splice(i, 1);
|
|
94
|
+
session.set(TILES, q.length ? q : undefined);
|
|
95
|
+
return true;
|
|
96
|
+
};
|
|
97
|
+
for (const d of session.get<TileEvent[]>(TILES) ?? []) if (d.kind === kind && claim(d.id)) h(d);
|
|
98
|
+
document.addEventListener('lbx:tile', (e) => {
|
|
99
|
+
const d = (e as CustomEvent<TileEvent>).detail;
|
|
100
|
+
if (d.kind === kind && claim(d.id)) h(d);
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
export const lumaClicks = () => tiles('click');
|
|
105
|
+
export const lumaCarts = () => tiles('add_to_cart');
|
|
106
|
+
|
|
107
|
+
export interface MutationContext {
|
|
108
|
+
/** The element matching the selector: added, or with nodes added inside it. */
|
|
109
|
+
element: Element;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The site's element `selector`, once in the page and each time it (or what
|
|
113
|
+
* is inside it) is added: a service of theirs that renders its results with
|
|
114
|
+
* no dataLayer event. Read when the browser is idle (the render has settled);
|
|
115
|
+
* never ours. `root`: only inside the first element it matches (default the document).
|
|
116
|
+
*/
|
|
117
|
+
export const mutationOf = (selector: string, opts?: { root?: string }): Trigger<MutationContext> => ({
|
|
118
|
+
key: `mutation:${selector}:${opts?.root ?? ''}`,
|
|
119
|
+
sub: (h) => {
|
|
120
|
+
const due = new Set<Element>();
|
|
121
|
+
let asked = false;
|
|
122
|
+
const fire = () => {
|
|
123
|
+
asked = false;
|
|
124
|
+
const els = [...due];
|
|
125
|
+
due.clear();
|
|
126
|
+
for (const element of els) if (element.isConnected) h({ element });
|
|
127
|
+
};
|
|
128
|
+
const add = (el: Element | null) => {
|
|
129
|
+
if (!el || el.closest('[data-lbx]')) return;
|
|
130
|
+
due.add(el);
|
|
131
|
+
if (asked) return;
|
|
132
|
+
asked = true;
|
|
133
|
+
if (typeof requestIdleCallback === 'function') requestIdleCallback(fire, { timeout: 1000 });
|
|
134
|
+
else setTimeout(fire, 50);
|
|
135
|
+
};
|
|
136
|
+
const look = (n: Node) => {
|
|
137
|
+
if (!(n instanceof Element)) return;
|
|
138
|
+
if (n.matches(selector)) add(n);
|
|
139
|
+
n.querySelectorAll(selector).forEach(add);
|
|
140
|
+
};
|
|
141
|
+
const root = (opts?.root && document.querySelector(opts.root)) || document.documentElement;
|
|
142
|
+
new MutationObserver((ms) => {
|
|
143
|
+
for (const m of ms) {
|
|
144
|
+
if (!m.addedNodes.length) continue;
|
|
145
|
+
add(m.target instanceof Element ? m.target.closest(selector) : null);
|
|
146
|
+
m.addedNodes.forEach(look);
|
|
147
|
+
}
|
|
148
|
+
}).observe(root, { childList: true, subtree: true });
|
|
149
|
+
root.querySelectorAll(selector).forEach(add);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export interface XhrContext {
|
|
154
|
+
url: string;
|
|
155
|
+
method: string;
|
|
156
|
+
/** Its JSON body (the text, where it is not JSON). */
|
|
157
|
+
response: any;
|
|
158
|
+
}
|
|
159
|
+
type XhrTest = (r: { url: string; method: string }) => boolean;
|
|
160
|
+
const xhrs: { test: XhrTest; h: (c: XhrContext) => void }[] = [];
|
|
161
|
+
const matching = (url: string, method: string) =>
|
|
162
|
+
xhrs.filter((x) => {
|
|
163
|
+
try {
|
|
164
|
+
return x.test({ url, method });
|
|
165
|
+
} catch (e) {
|
|
166
|
+
reportError('analytics xhrMatching', e);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const answered = (hs: typeof xhrs, url: string, method: string, text: string) => {
|
|
171
|
+
let response: any = text;
|
|
172
|
+
try {
|
|
173
|
+
response = JSON.parse(text);
|
|
174
|
+
} catch {}
|
|
175
|
+
for (const x of hs) x.h({ url, method, response });
|
|
176
|
+
};
|
|
177
|
+
let wrapped = false;
|
|
178
|
+
/** XMLHttpRequest and fetch, watched once: only a response a trigger asks for is read. */
|
|
179
|
+
const wrapXhr = () => {
|
|
180
|
+
if (wrapped) return;
|
|
181
|
+
wrapped = true;
|
|
182
|
+
const open = XMLHttpRequest.prototype.open;
|
|
183
|
+
XMLHttpRequest.prototype.open = function (this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
|
|
184
|
+
const u = new URL(url, location.href).href;
|
|
185
|
+
const m = method.toUpperCase();
|
|
186
|
+
this.addEventListener('load', () => {
|
|
187
|
+
const hs = this.status >= 200 && this.status < 300 ? matching(u, m) : [];
|
|
188
|
+
if (!hs.length) return;
|
|
189
|
+
const t = this.responseType === '' || this.responseType === 'text' ? this.responseText : this.responseType === 'json' ? JSON.stringify(this.response) : null;
|
|
190
|
+
if (t != null) answered(hs, u, m, t);
|
|
191
|
+
});
|
|
192
|
+
return (open as (...a: unknown[]) => void).call(this, method, url, ...rest);
|
|
193
|
+
} as typeof open;
|
|
194
|
+
const f = window.fetch;
|
|
195
|
+
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
|
196
|
+
const p = f.call(window, input, init);
|
|
197
|
+
const u = new URL(input instanceof Request ? input.url : String(input), location.href).href;
|
|
198
|
+
const m = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
199
|
+
const hs = matching(u, m);
|
|
200
|
+
if (hs.length)
|
|
201
|
+
p.then((r) => (r.ok ? r.clone().text() : null))
|
|
202
|
+
.then((t) => t != null && answered(hs, u, m, t))
|
|
203
|
+
.catch(() => {});
|
|
204
|
+
return p;
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* A response to one of the site's own requests (XMLHttpRequest or fetch)
|
|
209
|
+
* that `test` matches (`url` absolute, `method` upper case): a service of
|
|
210
|
+
* theirs that asks its own API (their suggestions). Requests made before the
|
|
211
|
+
* page loaded are not seen.
|
|
212
|
+
*/
|
|
213
|
+
export const xhrMatching = (test: XhrTest): Trigger<XhrContext> => ({
|
|
214
|
+
key: 'xhr',
|
|
215
|
+
sub: (h) => {
|
|
216
|
+
wrapXhr();
|
|
217
|
+
xhrs.push({ test, h });
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
export interface ManualContext<D = any> {
|
|
222
|
+
data: D;
|
|
223
|
+
}
|
|
224
|
+
/** A trigger the integration fires itself: `const { trigger, fire } = manual(); …on(trigger)…; fire(data)`. */
|
|
225
|
+
export const manual = <D = any>(): { trigger: Trigger<ManualContext<D>>; fire(data?: D): void } => {
|
|
226
|
+
const hs: ((c: ManualContext<D>) => void)[] = [];
|
|
227
|
+
return {
|
|
228
|
+
trigger: { key: 'manual', sub: (h) => void hs.push(h) },
|
|
229
|
+
fire: (data = {} as D) =>
|
|
230
|
+
hs.forEach((h) => {
|
|
231
|
+
try {
|
|
232
|
+
h({ data });
|
|
233
|
+
} catch (e) {
|
|
234
|
+
reportError('analytics manual', e);
|
|
235
|
+
}
|
|
236
|
+
}),
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// user.identify(fn): the signed-in shopper's own id, read on every event and
|
|
2
|
+
// sent as `user_id` (omitted when null).
|
|
3
|
+
//
|
|
4
|
+
// user.identify(() => window.shop?.customerId ?? null);
|
|
5
|
+
|
|
6
|
+
import { stamps } from './setup';
|
|
7
|
+
|
|
8
|
+
export const user = {
|
|
9
|
+
identify(fn: () => string | number | null | undefined) {
|
|
10
|
+
stamps.user = fn;
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// What analytics events are made of, without the page: object references,
|
|
2
|
+
// the site's dataLayer entries, the page a URL is (one pageview per page, not per filter). Pure: unit-tested
|
|
3
|
+
// (test/unit/analytics-wire.test.ts).
|
|
4
|
+
//
|
|
5
|
+
// The wire format is the Luigi's Box AI events API
|
|
6
|
+
// (docs.luigisbox.ai/analytics/event-reference): `interaction` (click,
|
|
7
|
+
// add_to_cart), `transaction`, and, as the legacy Luma sends them, `pv` and
|
|
8
|
+
// `list` (an original service's results: ours the API records itself).
|
|
9
|
+
|
|
10
|
+
import { P, TRACKING, withoutTracking } from '../core/routes';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An object reference, `<type>/<field>:<value>`
|
|
14
|
+
* (docs.luigisbox.ai/analytics/references). An `@id` value carries its type
|
|
15
|
+
* ("product/123"): a bare one is prefixed.
|
|
16
|
+
*/
|
|
17
|
+
export const ref = (type: string, field: string, value: unknown) => {
|
|
18
|
+
const v = String(value);
|
|
19
|
+
return `${type}/${field}:${field === '@id' && !v.startsWith(`${type}/`) ? `${type}/${v}` : v}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** A reference as the API accepts it: three non-empty parts, no `/` or `:` in the first two. */
|
|
23
|
+
export const isRef = (r: unknown): r is string => typeof r === 'string' && /^[^/:]+\/[^/:]+:.+/.test(r);
|
|
24
|
+
|
|
25
|
+
/** An event of the site's dataLayer, read (object form or gtag's). */
|
|
26
|
+
export interface DlEntry {
|
|
27
|
+
event: string;
|
|
28
|
+
data: Record<string, any>;
|
|
29
|
+
ecommerce: Record<string, any> | null;
|
|
30
|
+
/** A push of a page left before analytics ran there: that page, and when it was left. */
|
|
31
|
+
url?: string;
|
|
32
|
+
at?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** gtag's `arguments` (or one kept by a page left before analytics ran: `{ 0: 'event', 1: … }`). */
|
|
36
|
+
const isArgs = (x: any): x is ArrayLike<any> => !!x && typeof x === 'object' && typeof x[0] === 'string';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `{ event: 'purchase', ecommerce: {…} }` (GTM) or `gtag('event', 'purchase',
|
|
40
|
+
* {…})` (an `arguments` object: gtag's parameters are the ecommerce data).
|
|
41
|
+
*/
|
|
42
|
+
export const readDl = (x: any): DlEntry | null => {
|
|
43
|
+
if (isArgs(x)) {
|
|
44
|
+
if (x[0] === 'event' && typeof x[1] === 'string') {
|
|
45
|
+
const params = x[2] && typeof x[2] === 'object' ? x[2] : {};
|
|
46
|
+
return { event: x[1], data: { event: x[1], ...params }, ecommerce: params };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (x && typeof x === 'object' && typeof x.event === 'string') return { event: x.event, data: x, ecommerce: x.ecommerce && typeof x.ecommerce === 'object' ? x.ecommerce : null };
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const IGNORE = new RegExp(`${TRACKING}|^${P.replace('.', '\\.')}`);
|
|
55
|
+
/**
|
|
56
|
+
* The page a URL shows, for pageviews: without tracking params, the hash and
|
|
57
|
+
* our own `lbx.*` (a filter, a sort on our listing is the same page). A
|
|
58
|
+
* listing's own visit (its category, its query: the caller's `visit`) is one
|
|
59
|
+
* page whatever its filters write into the site's params.
|
|
60
|
+
*/
|
|
61
|
+
export const pageOf = (href: string, visit?: (u: URL) => string | null) => {
|
|
62
|
+
const u = withoutTracking(href, IGNORE);
|
|
63
|
+
return visit?.(u) ?? u.href;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The URL sent with events: without tracking params (gclid, utm_…) and the hash. */
|
|
67
|
+
export const eventUrl = (href: string) => withoutTracking(href).href;
|
package/src/api/token.ts
CHANGED
|
@@ -250,8 +250,9 @@ const tokens = (g: GlobalConfigType, origin?: string): Tokens => {
|
|
|
250
250
|
pow_nonce: nonce,
|
|
251
251
|
pow_solution: solution,
|
|
252
252
|
channel_id: g.channelId,
|
|
253
|
-
// One token for discovery and analytics events.
|
|
254
|
-
|
|
253
|
+
// One token for discovery and analytics events. Any other audience mints a token without
|
|
254
|
+
// permissions (docs.luigisbox.ai/authentication/browser-tokens): `/events` did, every event a 403.
|
|
255
|
+
audience: [`${API}/discovery`, `${API}/analytics`],
|
|
255
256
|
}),
|
|
256
257
|
},
|
|
257
258
|
TIMEOUT_MS,
|
package/src/core/integration.ts
CHANGED
|
@@ -42,6 +42,12 @@ export interface IntegrationFiles {
|
|
|
42
42
|
* lazy always (an eager one would be in the entry, where `_init.ts` is).
|
|
43
43
|
*/
|
|
44
44
|
widgetInits?: Lazy<any>;
|
|
45
|
+
/**
|
|
46
|
+
* `_analytics.ts`, lazy (`import.meta.glob('./*\/_analytics.ts', { import: 'default' })`):
|
|
47
|
+
* the analytics setup (chains of `@/analytics`), run by the analytics chunk
|
|
48
|
+
* (it is in it) once the page has loaded.
|
|
49
|
+
*/
|
|
50
|
+
analytics?: Lazy<any>;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/** A folder's style-host.css; a category page without its own uses the search page's. */
|
package/src/core/kept.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The site's dataLayer pushes of a page left before analytics ran on it (a
|
|
2
|
+
// purchase on a thank-you page closed at once): kept for the next page's
|
|
3
|
+
// analytics, which replays them as of this page and time (src/analytics/datalayer.ts).
|
|
4
|
+
// In the entry: it runs on `pagehide`, when no chunk can load any more.
|
|
5
|
+
|
|
6
|
+
import { session } from './storage';
|
|
7
|
+
|
|
8
|
+
export const KEPT = 'lbx:dl';
|
|
9
|
+
|
|
10
|
+
/** Keeps the pushes of the dataLayer `name` from index `from` on (the last 30, of the last 5 pages); the index kept up to. */
|
|
11
|
+
export const keepDl = (name: string, from: number) => {
|
|
12
|
+
const dl = (window as any)[name];
|
|
13
|
+
if (!(dl?.length > from)) return from;
|
|
14
|
+
// Each on its own: one that does not serialize (a GTM click's element, cyclic) is left out, not the rest.
|
|
15
|
+
// gtag's `arguments` become `{ 0: 'event', 1: … }` (readDl reads them); functions (GTM's callbacks) are dropped.
|
|
16
|
+
const e = dl.slice(from - dl.length).slice(-30).flatMap((x: unknown) => {
|
|
17
|
+
try {
|
|
18
|
+
return [JSON.parse(JSON.stringify(x))];
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
session.set(KEPT, [...(session.get<unknown[]>(KEPT) ?? []), { u: location.href, t: Date.now(), e }].slice(-5));
|
|
24
|
+
return dl.length;
|
|
25
|
+
};
|
|
@@ -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
|
+
};
|