@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 +69 -11
- package/index.d.ts +6 -1
- package/package.json +1 -1
- package/src/client.js +18 -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.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
|
-
|
|
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, 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/)
|
|
@@ -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
|
|
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
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, 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((
|
|
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
|