@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 +94 -10
- package/index.d.ts +12 -1
- package/package.json +1 -1
- package/src/client.js +21 -5
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.
|
|
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
|
-
|
|
61
|
-
|
|
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`.
|
|
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.
|
|
176
|
-
|
|
177
|
-
|
|
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)
|
|
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
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((
|
|
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((
|
|
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((
|
|
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
|