@recapi/client 0.2.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
@@ -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.2.0/index.js';
19
+ import { createClient } from 'https://api.example.com/sdk/0.4.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, why, 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/)
@@ -202,6 +250,14 @@ stands in for WordPress, run by `scripts/php.sh`) and runs the printed
202
250
  script against the SDK. The quick start fills in the current version and
203
251
  your address (`jssdk.WooCommerce`).
204
252
 
253
+ ### Magento
254
+
255
+ The Magento 2 module ([`sdk/php/magento`](../php/magento/README.md)) prints
256
+ the SDK on every storefront page for views and add-to-carts, and sends
257
+ orders from the server with the PHP library. With consent, its script
258
+ mirrors the visitor's id and clicked recommendations into a first-party
259
+ cookie so the server's purchases are credited like the SDK's own.
260
+
205
261
  ### Node
206
262
 
207
263
  ```js
@@ -221,6 +277,23 @@ await recapi.destroy(); // flushes queued events before exit
221
277
  Timers do not keep a Node process alive. Call `await client.flush()` or
222
278
  `await client.destroy()` before exiting, or queued events are lost.
223
279
 
280
+ ### Showing why an item is listed
281
+
282
+ Every item carries `why`, a short [reason code](../../docs/api.md#reason-codes)
283
+ that names no item of the visitor's history, so any page can show it. Pick
284
+ your own words, and show no label for a code you don't know (new ones may
285
+ come):
286
+
287
+ ```js
288
+ const LABELS = {
289
+ for_you: 'Picked for you', similar: 'Similar items', next: 'Up next',
290
+ trending: 'Trending now', popular: 'Popular', new: 'New',
291
+ };
292
+ for (const item of items) {
293
+ const label = LABELS[item.why]; // undefined for explore, more and future codes: no label
294
+ }
295
+ ```
296
+
224
297
  ## API
225
298
 
226
299
  ### `createClient(options)`
@@ -260,13 +333,17 @@ Timers do not keep a Node process alive. Call `await client.flush()` or
260
333
  control characters. A stored history entry whose id breaks the rule,
261
334
  left by an earlier SDK version, is dropped when the page loads.
262
335
  - **`recommend({ count, tagsAny, tagsAll, exclude, contextItemId, placement, discovery })`**
263
- resolves to `{ recommendationId, fallback, items: [{ itemId, score, slot }] }`.
336
+ resolves to `{ recommendationId, fallback, items: [{ itemId, score, slot, why, metadata }] }`.
337
+ `metadata` is the object you stored with the item
338
+ ([Item metadata](../../docs/api.md#item-metadata): name, picture, link,
339
+ price…), absent when it has none, so you can render the list without a
340
+ lookup. `why` is the item's [reason code](#showing-why-an-item-is-listed).
264
341
  It never retries and waits at most 1.5 s (less if `timeoutMs` is lower):
265
342
  a late recommendation is worthless. See [Fallback](#fallback) for what
266
343
  happens when the call fails. The user's rejected items (the newest 200)
267
344
  are added to `exclude`, after yours, up to the API's limit of 500. The SDK does not send `explain`, which needs
268
345
  a secret key: call the API from your backend for that.
269
- - **`popular({ tagsAny, tagsAll, count })`** resolves to `{ items: [{ itemId, score }] }`.
346
+ - **`popular({ tagsAny, tagsAll, count })`** resolves to `{ items: [{ itemId, score, why, metadata }] }`.
270
347
  A public key goes in `?key=`, so the response can be cached by a CDN and
271
348
  the request needs no CORS preflight. A secret key (Node) goes in the
272
349
  `Authorization` header, because the API refuses a secret key in a URL.
@@ -508,8 +585,15 @@ minifier, to keep zero dev dependencies.
508
585
 
509
586
  ## Changes
510
587
 
588
+ - 2026-10-31: version 0.4.0. Recommended and popular items carry `why`,
589
+ a reason code you can turn into a label for visitors
590
+ ([Showing why an item is listed](#showing-why-an-item-is-listed)).
591
+ - 2026-10-31: version 0.3.0. Recommended and popular items carry the
592
+ item's `metadata` (name, picture, link, price… stored with
593
+ `PUT /v1/items`) when it has some, fallback lists included
594
+ ([Client methods](#client-methods)).
511
595
  - 2026-10-30: version 0.2.0, the same code as 0.1.2: the first version
512
- published to npm by GitHub Actions (trusted publishing), with provenance.
596
+ published to npm by GitHub Actions (trusted publishing).
513
597
  - 2026-10-26: version 0.1.2. When `GET /v1/popular` fails too (recapi is
514
598
  down altogether), `recommend()` resolves with an empty list
515
599
  (`fallback: true`) instead of rejecting, and reports both errors to
package/index.d.ts CHANGED
@@ -99,10 +99,21 @@ export interface RecommendParams {
99
99
  discovery?: number;
100
100
  }
101
101
 
102
+ /** An item's reason code: map the ones you want to your own label, show none for an unknown one. */
103
+ export type ReasonCode =
104
+ | 'for_you' | 'similar' | 'next' | 'trending' | 'popular' | 'new' | 'explore' | 'more' | (string & {});
105
+
106
+ /** The object stored with the item (`PUT /v1/items`, `metadata`): name, picture, link, price... */
107
+ export type ItemMetadata = Record<string, unknown>;
108
+
102
109
  export interface RecommendedItem {
103
110
  itemId: string;
104
111
  score: number;
105
112
  slot: 'exploit' | 'explore' | 'backfill' | string;
113
+ /** Why the item is listed, to label it for visitors (docs/api.md, "Reason codes"). New codes may be added. */
114
+ why?: ReasonCode;
115
+ /** Absent when the item has no metadata. */
116
+ metadata?: ItemMetadata;
106
117
  }
107
118
 
108
119
  export interface RecommendResult {
@@ -125,7 +136,7 @@ export interface PopularParams {
125
136
  }
126
137
 
127
138
  export interface PopularResult {
128
- items: Array<{ itemId: string; score: number }>;
139
+ items: Array<{ itemId: string; score: number; why?: ReasonCode; metadata?: ItemMetadata }>;
129
140
  }
130
141
 
131
142
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recapi/client",
3
- "version": "0.2.0",
3
+ "version": "0.4.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, why?: 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, why?: string, 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,22 @@ export function createClient(options) {
584
584
  };
585
585
  }
586
586
 
587
+ /**
588
+ * An item of a list as the SDK returns it. `why` (the reason code) and
589
+ * `metadata` (the shop's own object, set with the item) are there only when
590
+ * the API sent them, so an item keeps the shape it had before they existed.
591
+ */
592
+ function toItem({ item_id: itemId, score, why, metadata }, slot) {
593
+ const item = slot === undefined ? { itemId, score } : { itemId, score, slot };
594
+ if (typeof why === 'string' && why !== '') {
595
+ item.why = why;
596
+ }
597
+ if (metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata) && Object.keys(metadata).length > 0) {
598
+ item.metadata = metadata;
599
+ }
600
+ return item;
601
+ }
602
+
587
603
  /**
588
604
  * Whether `recommend()` should fall back to popular items: recapi could not
589
605
  * answer in time or failed on its side. A 429 is the tenant's own limit