@luigisbox/luma 0.3.0 → 0.4.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 CHANGED
@@ -10,9 +10,9 @@ This package is what an integration is built with. Each integration lives in
10
10
  its own repository and depends on `@luigisbox/luma`
11
11
  ([Integration repository](#integration-repository)).
12
12
 
13
- > **Pre-release.** Until 1.0, a minor version (0.4.0) may change the
13
+ > **Pre-release.** Until 1.0, a minor version (0.5.0) may change the
14
14
  > integration API (config keys, template scope, the build); a patch version
15
- > (0.3.1) only fixes. Depend on `^0.3.0` to take fixes without breaking
15
+ > (0.4.1) only fixes. Depend on `^0.4.0` to take fixes without breaking
16
16
  > changes.
17
17
 
18
18
  ## Core principles
@@ -405,8 +405,8 @@ collect.purchase.on(dl('purchase')).read(fromGa4.purchase);
405
405
  | Event | What `.read()` returns |
406
406
  |---|---|
407
407
  | `pageView` | the page's reference, or `null` for a page without one. On `lumaPages()`, one pageview per page, sent at once. On any other trigger, one per page that waits for its reference from that trigger (`.timeout(ms)`, default 1000 ms) and is sent without one after that. One `pageView` line per file |
408
- | `click` | a product click: a reference, or `{ reference }` |
409
- | `addToCart` | `{ reference, count?, price? }`, or a list of them |
408
+ | `click` | a product click: a reference, or `{ reference, attributedToList? }` |
409
+ | `addToCart` | `{ reference, count?, price?, attributedToList? }`, or a list of them |
410
410
  | `purchase` | `{ id?, currency?, items: [{ reference, title, count, total_price, was_discounted? }] }`; sent once per `id`, so a reloaded thank-you page does not count twice |
411
411
  | `search`, `autocomplete`, `recommender(name)` | a list from the site's own service: `{ query?, items, filters? }`. Not sent on a page where Luma runs that service itself (the API records Luma's own results) |
412
412
 
@@ -428,12 +428,26 @@ collect.purchase.on(dl('purchase')).read(fromGa4.purchase);
428
428
 
429
429
  | Reader | Reads |
430
430
  |---|---|
431
- | `fromLuma.pageView`, `.click`, `.addToCart` | what Luma's own triggers saw (`lumaPages()`, `lumaClicks()`, `lumaCarts()`). Products are referenced by their catalog `@id` |
432
- | `fromGa4.addToCart`, `.purchase`, `.click`, `.pageView`, `.search`, `.autocomplete`, `.recommender` | GA4 ecommerce: `add_to_cart`, `purchase`, `select_item`, `view_item`, `view_item_list`. The data is taken from the push's `ecommerce` (or `gtag()`'s parameters), or from the push itself when its `items` are there. Items are referenced by `ref.product(item_id)`; purchases get line totals, and `was_discounted` from `discount` |
431
+ | `fromLuma.pageView`, `.click`, `.addToCart` | what Luma's own triggers saw (`lumaPages()`, `lumaClicks()`, `lumaCarts()`). Products are referenced by their catalog `@id`. Clicks and add-to-carts are in a list (`attributedToList: true`) |
432
+ | `fromGa4.addToCart`, `.purchase`, `.click`, `.pageView`, `.search`, `.autocomplete`, `.recommender` | GA4 ecommerce: `add_to_cart`, `purchase`, `select_item`, `view_item`, `view_item_list`. The data is taken from the push's `ecommerce` (or `gtag()`'s parameters), or from the push itself when its `items` are there. Items are referenced by `ref.product(item_id)`; purchases get line totals, and `was_discounted` from `discount`. `select_item` is a pick from a list (`attributedToList: true`); `add_to_cart` is not |
433
433
 
434
434
  Each reader belongs to one event: `collect.purchase…read(fromGa4.addToCart)`
435
435
  is a type error, and throws when the file runs.
436
436
 
437
+ **Clicks and add-to-carts in a list.** Luigi's Box credits a click or an
438
+ add-to-cart to the last search, suggestions, recommendation or listing the
439
+ shopper saw the product in only when the event says it happened in a list:
440
+ `attributedToList: true` from `.read()`, sent as `attributed_to_list: true`.
441
+ A click without it (a bare reference) counts for the product, never for a
442
+ list, so a click on a product page's own link is not credited to a search the
443
+ shopper made earlier. Luma's own tiles always say it (`fromLuma.click`,
444
+ `fromLuma.addToCart`, autocomplete picks included), and so does GA4's
445
+ `select_item` (`fromGa4.click`). A click on the site's own list, read with a
446
+ function of yours, says it when the element is one of that list's results:
447
+ return `{ reference, attributedToList: true }` (below). The list itself must
448
+ be known too: Luma's own results are; the site's own are when the setup
449
+ collects them (`collect.search`, `.autocomplete`, `.recommender(name)`).
450
+
437
451
  **References** follow `<type>/<field>:<value>`
438
452
  ([docs](https://docs.luigisbox.ai/analytics/references/)).
439
453
  `ref.product(value)` uses the field set by `ref.product.by(field)` (default
@@ -473,8 +487,8 @@ collect.search.on(mutationOf('#results')).once().read(({ element }) => ({
473
487
  query: { string: new URLSearchParams(location.search).get('q') ?? '' },
474
488
  items: [...element.querySelectorAll('[data-sku]')].map((tile, n) => ({ title: tile.textContent ?? '', type: 'product', reference: ref.product(tile.getAttribute('data-sku')!), position: n + 1 })),
475
489
  }));
476
- // clicks on those results
477
- collect.click.on(domClick('#results [data-sku] a')).read(({ element }) => ref.product(element.closest('[data-sku]')!.getAttribute('data-sku')!));
490
+ // clicks on those results: in that list
491
+ collect.click.on(domClick('#results [data-sku] a')).read(({ element }) => ({ reference: ref.product(element.closest('[data-sku]')!.getAttribute('data-sku')!), attributedToList: true }));
478
492
  // the site's suggestions, from its own API
479
493
  collect.autocomplete.on(xhrMatching(({ url }) => url.includes('/suggest'))).read(({ response }) => ({ items: /* … */ [] }));
480
494
  // the product page's product, from GA4's view_item
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luigisbox/luma",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -26,6 +26,14 @@ export interface AddToCart {
26
26
  reference: string;
27
27
  count?: number;
28
28
  price?: number;
29
+ /** Added in a list (a tile's cart button): the platform credits it to that list. */
30
+ attributedToList?: boolean;
31
+ }
32
+ /** One click: what `collect.click` sends (a bare reference: not in a list). */
33
+ export interface Click {
34
+ reference: string;
35
+ /** Picked in a list (a search, suggestions, a recommender, a listing): the platform credits it to that list. */
36
+ attributedToList?: boolean;
29
37
  }
30
38
  /** A purchase: what `collect.purchase` sends, once per `id`. */
31
39
  export interface Purchase {
@@ -42,7 +50,7 @@ export interface List {
42
50
  /** What `.read()` returns, per event (null or undefined: nothing sent). */
43
51
  export interface Reads {
44
52
  pageView: string | null | undefined;
45
- click: string | { reference: string };
53
+ click: string | Click;
46
54
  addToCart: AddToCart | AddToCart[];
47
55
  purchase: Purchase;
48
56
  search: List;
@@ -3,9 +3,13 @@
3
3
  // - pageView: `pv` per page (./pageview.ts);
4
4
  // - search, autocomplete, recommender(name): a `list` event of the site's own
5
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);
6
+ // - click: an `interaction` click (a reference, or `{ reference, attributedToList? }`);
7
+ // - addToCart: `interaction` add_to_cart per item (`{ reference, count?, price?, attributedToList? }` or a list);
8
8
  // - purchase: a `transaction` (`{ id?, currency?, items }`), once per id.
9
+ // `attributedToList`: the product was picked in a list (a search, suggestions,
10
+ // a recommender, a listing): sent as `attributed_to_list: true`, the only
11
+ // interactions the platform credits to the last list in the session that
12
+ // held the product. Without it the interaction counts for the product alone.
9
13
 
10
14
  import { emit } from './outbox';
11
15
  import { state } from './setup';
@@ -26,24 +30,27 @@ export const autocomplete = /* @__PURE__ */ surface<'autocomplete'>('autocomplet
26
30
  /** The site's own recommender `name` (silent where ours of that name runs): a `list` event. */
27
31
  export const recommender = (name: string) => surface<'recommender'>(`recommender:${name}`, list('Recommendation'));
28
32
 
29
- /** A product click: the reference (or `{ reference }`). */
33
+ /** `attributed_to_list: true` where the product was picked in a list (only then: absent is false). */
34
+ const inList = (x: { attributedToList?: boolean } | undefined) => (x?.attributedToList === true ? { attributed_to_list: true } : {});
35
+
36
+ /** A product click: the reference (or `{ reference, attributedToList? }`). */
30
37
  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 });
38
+ const o = typeof p === 'string' ? { reference: p } : p;
39
+ if (isRef(o?.reference)) emit({ type: 'interaction', interaction_type: 'click', listing_ab_test: false, reference: o.reference, ...inList(o) });
33
40
  });
34
41
 
35
42
  const currency = () => state.g?.currency;
36
43
  /** When the last add-to-cart on our tiles was: the site's own push its cart then makes is the same one. */
37
44
  let ourCart = 0;
38
45
  const OUR_CART_MS = 10_000;
39
- /** Add-to-cart: `{ reference, count?, price? }` or a list of them. */
46
+ /** Add-to-cart: `{ reference, count?, price?, attributedToList? }` or a list of them. */
40
47
  export const addToCart = /* @__PURE__ */ surface<'addToCart'>('addToCart', (p) => {
41
48
  const all: (AddToCart & { [OURS]?: true })[] = Array.isArray(p) ? p : [p];
42
49
  if (all.some((x) => x?.[OURS])) ourCart = Date.now();
43
50
  else if (Date.now() - ourCart < OUR_CART_MS) return void (ourCart = 0);
44
51
  for (const x of all) {
45
52
  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() } : {}) });
53
+ 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() } : {}), ...inList(x) });
47
54
  }
48
55
  });
49
56
 
@@ -56,8 +56,11 @@ export const purchase = /* @__PURE__ */ reader('purchase', (ctx: Ctx) => {
56
56
  });
57
57
  return items.length ? { id: e.transaction_id, currency: e.currency, items } : null;
58
58
  });
59
- /** select_item: the product clicked. */
60
- export const click = /* @__PURE__ */ reader('click', first);
59
+ /** select_item (GA4: an item picked from a list): the product clicked, in its list. */
60
+ export const click = /* @__PURE__ */ reader('click', (ctx: Ctx) => {
61
+ const reference = first(ctx);
62
+ return reference ? { reference, attributedToList: true } : null;
63
+ });
61
64
  /** view_item: the page's product. */
62
65
  export const pageView = /* @__PURE__ */ reader('pageView', first);
63
66
  /** view_item_list of their search: its items, the query from search_term. */
@@ -6,7 +6,9 @@
6
6
  // collect.addToCart.on(lumaCarts()).read(fromLuma.addToCart);
7
7
  //
8
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.
9
+ // whatever ref.product.by() says for the site's own data. Every tile of ours
10
+ // is in a list (a search, suggestions, a recommender, a listing): its clicks
11
+ // and add-to-carts are attributed to it.
10
12
 
11
13
  import type { TileEvent } from '../core/tiles';
12
14
  import { reader } from './chain';
@@ -15,7 +17,7 @@ import type { Page } from './page';
15
17
 
16
18
  /** A page: no reference of its own. */
17
19
  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
+ /** A click on one of our tiles: the product clicked, in its list. */
21
+ export const click = /* @__PURE__ */ reader('click', (t: TileEvent) => ({ reference: t.reference, attributedToList: true }));
20
22
  /** `[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 }));
23
+ export const addToCart = /* @__PURE__ */ reader('addToCart', (t: TileEvent) => ({ reference: t.reference, count: t.count ?? 1, ...(t.price != null ? { price: t.price } : {}), attributedToList: true, [OURS]: true as const }));
@@ -19,6 +19,6 @@ export { user } from './user';
19
19
  export { ref } from './ref';
20
20
  export { dl, domClick, lumaCarts, lumaClicks, lumaPages, manual, mutationOf, urlChange, xhrMatching } from './triggers';
21
21
  export type { DlContext, DomClickContext, ManualContext, MutationContext, UrlChangeContext, XhrContext } from './triggers';
22
- export type { AddToCart, List, Purchase, Read, Trigger } from './chain';
22
+ export type { AddToCart, Click, List, Purchase, Read, Trigger } from './chain';
23
23
  export type { Page } from './page';
24
24
  export type { TileEvent } from '../core/tiles';
@@ -35,9 +35,22 @@ export interface EarlyRoute {
35
35
  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
36
  // reserveSelector: set by loadConfig (default: the first anchor).
37
37
 
38
- /** Only what the snippet can express: a path regex and a query param (no custom readQuery/active). */
39
- export const searchRule = (c: SearchConfigType, hostCss: string): EarlyRoute | null =>
40
- c.urlMatch && c.queryParam && !c.readQuery && !c.active ? { id: 'search', path: c.urlMatch, need: [c.queryParam], css: holdCss(hostCss, c.reserveSelector!) } : null;
38
+ /** Capture groups in a regex source (its alternation with the empty string always matches). */
39
+ const groups = (re: string) => new RegExp(`${re}|`).exec('')!.length - 1;
40
+
41
+ /**
42
+ * Only what the snippet can express (no custom readQuery/active): a path
43
+ * regex and a query param, or a path regex whose capture group 1 is the query
44
+ * (/search/<q>/). The snippet cannot check that capture (the bundle decodes
45
+ * and trims it), so it also holds a blank one (/search/+/); the bundle
46
+ * rejects that URL and drops the unclaimed hold at boot, as for plpRule's `active`.
47
+ */
48
+ export const searchRule = (c: SearchConfigType, hostCss: string): EarlyRoute | null => {
49
+ if (!c.urlMatch || c.readQuery || c.active) return null;
50
+ const css = holdCss(hostCss, c.reserveSelector!);
51
+ if (c.queryParam) return { id: 'search', path: c.urlMatch, need: [c.queryParam], css };
52
+ return groups(c.urlMatch) ? { id: 'search', path: c.urlMatch, css } : null;
53
+ };
41
54
 
42
55
  /**
43
56
  * Param names the category route can express (values are checked by the bundle).