@recapi/client 0.1.2 → 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 CHANGED
@@ -16,7 +16,7 @@ are, gzipped, and browsers cache them for a year:
16
16
 
17
17
  ```html
18
18
  <script type="module">
19
- import { createClient } from 'https://api.example.com/sdk/0.1.2/index.js';
19
+ import { createClient } from 'https://api.example.com/sdk/0.3.0/index.js';
20
20
 
21
21
  const recapi = createClient({ apiKey: 'rk_pub_…', baseUrl: 'https://api.example.com' });
22
22
  recapi.track('item-42', 'view'); // and the calls of the quick start below
@@ -57,8 +57,16 @@ recapi.track('item-42', 'view');
57
57
  // resolves with popular items instead, and with an empty list if recapi is
58
58
  // down altogether (hide the slot): no try/catch needed. Give the widget's
59
59
  // container a CSS min-height so the page doesn't jump when the items arrive.
60
- const { items } = await recapi.recommend({ count: 8, tagsAny: ['sports'] });
61
- render(items); // [{ itemId, score, slot }]
60
+ // Below the fold, ask only once it nears the screen: each list is billed
61
+ // and counted as shown, and many visitors never scroll that far.
62
+ const slot = document.getElementById('recommendations');
63
+ const observer = new IntersectionObserver(async (entries) => {
64
+ if (!entries.some((entry) => entry.isIntersecting)) return;
65
+ observer.disconnect();
66
+ const { items } = await recapi.recommend({ count: 8, tagsAny: ['sports'] });
67
+ render(slot, items); // [{ itemId, score, slot, metadata }]
68
+ }, { rootMargin: '0px 0px 400px 0px' });
69
+ observer.observe(slot);
62
70
 
63
71
  // Later, the user clicks a recommended item and lands on its page (a
64
72
  // view). No need to pass the recommendation id: the SDK remembers which
@@ -82,7 +90,13 @@ in two places, both shown with your address in the dashboard's quick start:
82
90
  `product.handle` and `customer.id`, which a section reads as the layout
83
91
  does, and it stays far below the setting's 50 KB. It identifies a
84
92
  logged-in customer, tracks product views and fills any `<div id="recapi-recommendations">` with 4 product
85
- cards it loads from Shopify's `/products/<handle>.js`. The product handle
93
+ cards it loads from Shopify's `/products/<handle>.js`. Like the row of
94
+ Shopify's own themes, it asks for them only once the div comes within
95
+ 400px of the screen (an `IntersectionObserver`, at once in a browser
96
+ without one): each list is billed and counted as an impression, and a
97
+ visitor who never scrolls down saw none. The view goes at page load. A
98
+ shop moving from an earlier snippet sees fewer served lists and its CTR
99
+ rise once, since lists nobody reached no longer count. The product handle
86
100
  is the item id. recapi has no catalog of a Shopify shop, so the snippet
87
101
  asks for 6 and skips products that are sold out (`available: false`) or
88
102
  deleted (404). All 6 count as one served list: CTR and lift are per list,
@@ -116,6 +130,16 @@ in two places, both shown with your address in the dashboard's quick start:
116
130
  pixel writes it back only if the page didn't change it since the pixel
117
131
  read it (its event is sent either way).
118
132
 
133
+ Every Online Store 2.0 theme already shows Shopify's own product
134
+ recommendations on the product page ("Related products", "You may also
135
+ like" or "Product recommendations",
136
+ [built from orders, descriptions and collections](https://shopify.dev/docs/storefronts/themes/product-merchandising/recommendations/related-products)).
137
+ The quick start tells the merchant to hide that section with its eye icon
138
+ (one click to undo) and put the Custom Liquid section with the `<div>` in
139
+ its place, and the same in a cart that shows Shopify's complementary
140
+ products: two rows split the clicks and dilute a pilot's lift. It stays
141
+ hidden for the whole test.
142
+
119
143
  Both files pass the same [`project`](#createclientoptions), which the quick start fills
120
144
  in for your environment, so they share the visitor's state whichever public
121
145
  key each holds: keys are shown once, and a pixel added later with a new key
@@ -172,9 +196,33 @@ product pages stored and is credited to the recommendation clicked: no
172
196
  pixel and no cart attribute. Page caches serve product pages as stored
173
197
  HTML, so the plugin prints only what is the same for every visitor (the
174
198
  product id, the Store API's address); only the order-received page, which
175
- caches skip, prints the order. The widget fills
176
- `<div id="recapi-recommendations">` like the Shopify snippet's, loading the
177
- 6 recommended products in one request to WooCommerce's public Store API
199
+ caches skip, prints the order.
200
+
201
+ The plugin prints the row's `<div id="recapi-recommendations">` in place of
202
+ WooCommerce's Related products on product pages, so a shop on any theme
203
+ shows it with no editing and never two rows (two split the clicks and slow
204
+ the lift's verdict); deactivating the plugin brings Related products back.
205
+ Classic themes: from `template_redirect`, it swaps
206
+ `woocommerce_output_related_products` on
207
+ `woocommerce_after_single_product_summary` (priority 20) for the div, only
208
+ on product pages and only if the theme still has it there (a theme that
209
+ removed it keeps its choice). Block themes: `render_block` filters return
210
+ the div for the Related Products block and for a Product Collection showing
211
+ `woocommerce/product-collection/related`. Elsewhere (page builders, the cart
212
+ page, themes that print related products their own way) the `[recapi]`
213
+ shortcode prints it. The div is printed once per page, and if the merchant
214
+ pasted one too, the script fills the first and removes the others.
215
+
216
+ In Related products' place the row keeps their heading: WooCommerce's
217
+ translated "Related products" through the
218
+ `woocommerce_product_related_products_heading` filter its own template
219
+ applies, so a theme's wording carries over (an empty value shows no title).
220
+ The script inserts it as `<h2 class="recapi-heading">` before the div once
221
+ the cards fill, as text, so a row that stays hidden leaves no title. The
222
+ shortcode shows none unless given one: `[recapi heading="You may also like"]`.
223
+
224
+ The widget fills it like the Shopify snippet's, once it nears the screen,
225
+ loading the 6 recommended products in one request to WooCommerce's public Store API
178
226
  (`/wc/store/v1/products?include=…`) and skipping those not purchasable or
179
227
  out of stock. Consent comes from the
180
228
  [WP Consent API](https://wordpress.org/plugins/wp-consent-api/)
@@ -260,13 +308,17 @@ Timers do not keep a Node process alive. Call `await client.flush()` or
260
308
  control characters. A stored history entry whose id breaks the rule,
261
309
  left by an earlier SDK version, is dropped when the page loads.
262
310
  - **`recommend({ count, tagsAny, tagsAll, exclude, contextItemId, placement, discovery })`**
263
- resolves to `{ recommendationId, fallback, items: [{ itemId, score, slot }] }`.
311
+ resolves to `{ recommendationId, fallback, items: [{ itemId, score, slot, metadata }] }`.
312
+ `metadata` is the object you stored with the item
313
+ ([Item metadata](../../docs/api.md#item-metadata): name, picture, link,
314
+ price…), absent when it has none, so you can render the list without a
315
+ lookup.
264
316
  It never retries and waits at most 1.5 s (less if `timeoutMs` is lower):
265
317
  a late recommendation is worthless. See [Fallback](#fallback) for what
266
318
  happens when the call fails. The user's rejected items (the newest 200)
267
319
  are added to `exclude`, after yours, up to the API's limit of 500. The SDK does not send `explain`, which needs
268
320
  a secret key: call the API from your backend for that.
269
- - **`popular({ tagsAny, tagsAll, count })`** resolves to `{ items: [{ itemId, score }] }`.
321
+ - **`popular({ tagsAny, tagsAll, count })`** resolves to `{ items: [{ itemId, score, metadata }] }`.
270
322
  A public key goes in `?key=`, so the response can be cached by a CDN and
271
323
  the request needs no CORS preflight. A secret key (Node) goes in the
272
324
  `Authorization` header, because the API refuses a secret key in a URL.
@@ -508,6 +560,12 @@ minifier, to keep zero dev dependencies.
508
560
 
509
561
  ## Changes
510
562
 
563
+ - 2026-10-31: version 0.3.0. Recommended and popular items carry the
564
+ item's `metadata` (name, picture, link, price… stored with
565
+ `PUT /v1/items`) when it has some, fallback lists included
566
+ ([Client methods](#client-methods)).
567
+ - 2026-10-30: version 0.2.0, the same code as 0.1.2: the first version
568
+ published to npm by GitHub Actions (trusted publishing).
511
569
  - 2026-10-26: version 0.1.2. When `GET /v1/popular` fails too (recapi is
512
570
  down altogether), `recommend()` resolves with an empty list
513
571
  (`fallback: true`) instead of rejecting, and reports both errors to
@@ -540,8 +598,8 @@ to `published.json` (the digest is `sha256sum src/*.js | sha256sum`, then
540
598
  each file's exports), and change the `/sdk/<version>/` paths in this
541
599
  README and `docs/api.md` (the quick start follows by itself). Never edit
542
600
  an existing entry of `published.json`.
543
- Once production runs that version, the release service publishes it to
544
- npm as `@recapi/client` ([deployment.md](../../docs/deployment.md#publishing-the-javascript-library)):
601
+ Once production runs that version, the release service has GitHub publish
602
+ it to npm as `@recapi/client` ([deployment.md](../../docs/deployment.md#publishing-the-javascript-library)):
545
603
  nobody runs `npm publish` by hand.
546
604
 
547
605
  Every older path, whatever its version number, redirects to the new
package/index.d.ts CHANGED
@@ -99,10 +99,15 @@ export interface RecommendParams {
99
99
  discovery?: number;
100
100
  }
101
101
 
102
+ /** The object stored with the item (`PUT /v1/items`, `metadata`): name, picture, link, price... */
103
+ export type ItemMetadata = Record<string, unknown>;
104
+
102
105
  export interface RecommendedItem {
103
106
  itemId: string;
104
107
  score: number;
105
108
  slot: 'exploit' | 'explore' | 'backfill' | string;
109
+ /** Absent when the item has no metadata. */
110
+ metadata?: ItemMetadata;
106
111
  }
107
112
 
108
113
  export interface RecommendResult {
@@ -125,7 +130,7 @@ export interface PopularParams {
125
130
  }
126
131
 
127
132
  export interface PopularResult {
128
- items: Array<{ itemId: string; score: number }>;
133
+ items: Array<{ itemId: string; score: number; metadata?: ItemMetadata }>;
129
134
  }
130
135
 
131
136
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recapi/client",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Browser and Node client for the recapi recommendation API: local decayed history, event batching, attribution.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/client.js CHANGED
@@ -323,7 +323,7 @@ export function createClient(options) {
323
323
  }
324
324
 
325
325
  const recommendationId = response.recommendation_id;
326
- const items = response.items.map(({ item_id: itemId, score, slot }) => ({ itemId, score, slot }));
326
+ const items = response.items.map((item) => toItem(item, item.slot));
327
327
  mutate(() => {
328
328
  const t = now();
329
329
  for (const { itemId } of items) {
@@ -360,7 +360,7 @@ export function createClient(options) {
360
360
  return items
361
361
  .filter(({ item_id: itemId }) => !skip.has(itemId))
362
362
  .slice(0, n)
363
- .map(({ item_id: itemId, score }) => ({ itemId, score, slot: 'backfill' }));
363
+ .map((item) => toItem(item, 'backfill'));
364
364
  }
365
365
 
366
366
  /**
@@ -448,7 +448,7 @@ export function createClient(options) {
448
448
  * @param {{ count?: number, tagsAny?: string[], tagsAll?: string[], exclude?: string[],
449
449
  * contextItemId?: string, placement?: string, discovery?: number }} [params]
450
450
  * @returns {Promise<{ recommendationId: string | null, fallback: boolean,
451
- * items: Array<{ itemId: string, score: number, slot: string }> }>}
451
+ * items: Array<{ itemId: string, score: number, slot: string, metadata?: object }> }>}
452
452
  */
453
453
  async recommend(params = {}) {
454
454
  const deadline = Math.min(timeoutMs, RECOMMEND_TIMEOUT_MS);
@@ -476,11 +476,11 @@ export function createClient(options) {
476
476
  * preflight; a secret key stays in the `Authorization` header.
477
477
  *
478
478
  * @param {{ tagsAny?: string[], tagsAll?: string[], count?: number }} [params]
479
- * @returns {Promise<{ items: Array<{ itemId: string, score: number }> }>}
479
+ * @returns {Promise<{ items: Array<{ itemId: string, score: number, metadata?: object }> }>}
480
480
  */
481
481
  async popular(params = {}) {
482
482
  const items = await fetchPopular(params);
483
- return { items: items.map(({ item_id: itemId, score }) => ({ itemId, score })) };
483
+ return { items: items.map((item) => toItem(item)) };
484
484
  },
485
485
 
486
486
  /**
@@ -584,6 +584,19 @@ export function createClient(options) {
584
584
  };
585
585
  }
586
586
 
587
+ /**
588
+ * An item of a list as the SDK returns it. `metadata` (the shop's own
589
+ * object, set with the item) is there only when the item has some, so an
590
+ * item without it keeps the shape it had before metadata existed.
591
+ */
592
+ function toItem({ item_id: itemId, score, metadata }, slot) {
593
+ const item = slot === undefined ? { itemId, score } : { itemId, score, slot };
594
+ if (metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata) && Object.keys(metadata).length > 0) {
595
+ item.metadata = metadata;
596
+ }
597
+ return item;
598
+ }
599
+
587
600
  /**
588
601
  * Whether `recommend()` should fall back to popular items: recapi could not
589
602
  * answer in time or failed on its side. A 429 is the tenant's own limit