@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.
- package/README.md +208 -76
- package/build/vite.mjs +9 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +10 -5
- package/docs/ACCESSIBILITY.md +9 -5
- 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/entries.ts +10 -6
- package/src/core/integration.ts +6 -0
- package/src/core/kept.ts +25 -0
- package/src/core/stash.ts +24 -4
- package/src/core/tiles.ts +137 -0
- package/src/main.ts +54 -4
- package/src/types.ts +29 -2
- package/src/widgets/autocomplete/index.ts +206 -31
- package/src/widgets/autocomplete/style-autocomplete.css +11 -5
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
- package/src/widgets/listing/element.ts +212 -21
- package/src/widgets/listing/instant.ts +83 -23
- package/src/widgets/listing/stash.ts +31 -8
- package/src/widgets/listing/view.ts +12 -2
- package/src/widgets/recommender/index.ts +15 -4
- package/src/widgets/search/range.ts +40 -18
- package/src/widgets/search/style-search.css +2 -2
|
@@ -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') };
|
|
@@ -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/entries.ts
CHANGED
|
@@ -16,10 +16,14 @@ export const isOwnEntry = (href: string) => own.has(canon(href));
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* A history entry of ours that only stands for something open (the
|
|
19
|
-
* autocomplete's phone sheet
|
|
20
|
-
* hears that popstate (the URL is the
|
|
21
|
-
* popstate was
|
|
19
|
+
* autocomplete's phone sheet, the listing's staged filter drawer): Back
|
|
20
|
+
* closes it, and no router of the host's hears that popstate (the URL is the
|
|
21
|
+
* same). Each handler returns true when the popstate was its entry's (set by
|
|
22
|
+
* the widget that pushed it). Returns the removal.
|
|
22
23
|
*/
|
|
23
|
-
|
|
24
|
-
export const onOwnBack = (f: () => boolean) =>
|
|
25
|
-
export const ownBack = () =>
|
|
24
|
+
const backs = new Set<() => boolean>();
|
|
25
|
+
export const onOwnBack = (f: () => boolean) => (backs.add(f), () => void backs.delete(f));
|
|
26
|
+
export const ownBack = () => {
|
|
27
|
+
for (const f of backs) if (f()) return true;
|
|
28
|
+
return false;
|
|
29
|
+
};
|
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
|
+
};
|
package/src/core/stash.ts
CHANGED
|
@@ -17,21 +17,41 @@ export const navigation = () => {
|
|
|
17
17
|
}
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A listing left for another page, loaded again within this long by a reload
|
|
22
|
+
* or a navigation from itself (a server round trip: a "compare" or "add to
|
|
23
|
+
* cart" link that redirects back to the page it came from): the listing as
|
|
24
|
+
* left, as for Back.
|
|
25
|
+
*/
|
|
26
|
+
export const ROUND_TRIP_MS = 60e3;
|
|
27
|
+
|
|
28
|
+
/** The page loaded from itself: its referrer is this page (a redirect back keeps the referrer of the page that started it). */
|
|
29
|
+
const fromItself = () => {
|
|
30
|
+
try {
|
|
31
|
+
return !!document.referrer && pageKey(document.referrer) === pageKey(location.href);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
20
37
|
/**
|
|
21
38
|
* Which load a stash kind is for: a typed query's page by a navigation (or a
|
|
22
39
|
* prerender of it, `prerender` from its start through its activation), a
|
|
23
|
-
* listing left for a product by back/forward
|
|
40
|
+
* listing left for a product by back/forward, or by a reload or a navigation
|
|
41
|
+
* from itself within ROUND_TRIP_MS of leaving (`at`: when it was written).
|
|
24
42
|
*/
|
|
25
|
-
export const stashFits = (kind: string) => {
|
|
43
|
+
export const stashFits = (kind: string, at = 0) => {
|
|
26
44
|
const t = navigation().type;
|
|
27
|
-
|
|
45
|
+
if (kind !== 'back') return t === 'navigate' || t === 'prerender';
|
|
46
|
+
if (t === 'back_forward') return true;
|
|
47
|
+
return Date.now() - at < ROUND_TRIP_MS && (t === 'reload' || (t === 'navigate' && fromItself()));
|
|
28
48
|
};
|
|
29
49
|
|
|
30
50
|
/** A stash this load will likely use (the listing checks it fully). Reads only the start of the entry. */
|
|
31
51
|
export const hasStash = (href: string) => {
|
|
32
52
|
try {
|
|
33
53
|
const head = sessionStorage.getItem(stashKey(href))?.slice(0, 60);
|
|
34
|
-
return !!head && stashFits(head.includes('"kind":"back"') ? 'back' : 'spec');
|
|
54
|
+
return !!head && stashFits(head.includes('"kind":"back"') ? 'back' : 'spec', Number(/"at":(\d+)/.exec(head)?.[1]) || 0);
|
|
35
55
|
} catch {
|
|
36
56
|
return false;
|
|
37
57
|
}
|