@recapi/client 0.5.0 → 0.6.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.5.0/index.js';
19
+ import { createClient } from 'https://api.example.com/sdk/0.6.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
@@ -245,6 +245,31 @@ does when its consent API fails to load. It declares compatibility with
245
245
  WooCommerce's order tables (HPOS) and checkout blocks when installed as a
246
246
  plugin, and registers with the WP Consent API.
247
247
 
248
+ With a secret key pasted on WooCommerce → recapi (stored as the
249
+ `recapi_secret_key` option, not autoloaded, and never printed back), the
250
+ plugin describes the shop's products itself. Each product changed in a
251
+ request (`woocommerce_new_product`, `woocommerce_update_product`, a stock
252
+ status change, a variation's change as its parent's, a status change such as
253
+ trashing or a scheduled product going live, a deletion) is read again at
254
+ the end of the request and sent once the page is out, as one
255
+ `PUT /v1/items/{item_id}` or one `POST /v1/items:batch`: one try of at most
256
+ 2 s through WordPress's HTTP API, a failure logged to WooCommerce → Status →
257
+ Logs (source `recapi`), so a recapi outage never fails a save. A WP-Cron
258
+ event (`recapi_sync`, daily, first run when the key is saved) sends the
259
+ whole catalog, 200 products read and 1,000 sent at a time, oldest first,
260
+ with three tries per batch against an outage; the page shows when it last
261
+ ran, how many products it sent and why any were refused. Every item is
262
+ complete: `active` only while the product is published, not hidden from the
263
+ catalog, purchasable and in stock (the Store API cards' rule), its category
264
+ names as `tags` (not "Uncategorized"), its creation date as `published_at`,
265
+ and `name`, `url`, `picture` (the `woocommerce_thumbnail` size) and `price`
266
+ (as the shop displays it, in its currency) as `metadata`, shrunk below
267
+ 2,048 bytes as the Magento module does (picture first, then the name). The
268
+ row then draws every card from it, with no Store API call. Only items that
269
+ changed are billed. The plugin uses WordPress's HTTP API rather than the PHP
270
+ library so it stays one file (a snippet holds it) and follows the site's
271
+ proxy settings.
272
+
248
273
  Have past orders? Download them from WooCommerce → recapi and upload the
249
274
  file on Import data. The plugin writes the last 90 days of processing and
250
275
  completed orders as the dashboard's `user_id,item_id,level,timestamp` CSV,
@@ -302,7 +327,7 @@ come):
302
327
  ```js
303
328
  const LABELS = {
304
329
  for_you: 'Picked for you', similar: 'Similar items', next: 'Up next',
305
- trending: 'Trending now', popular: 'Popular', new: 'New',
330
+ bought_together: 'Often bought together', trending: 'Trending now', popular: 'Popular', new: 'New',
306
331
  };
307
332
  for (const item of items) {
308
333
  const label = LABELS[item.why]; // undefined for explore, more and future codes: no label
@@ -373,6 +398,9 @@ for (const item of items) {
373
398
  - **`getHistory()`** returns `{ history, recent, rejected }` exactly as the
374
399
  next request would send it (`rejected` goes into `exclude`). Use it for
375
400
  debugging.
401
+ - **`getAnonId()`** returns this device's anonymous id, the `anon_id` its
402
+ events carry, or `null` without consent and under Do Not Track. See
403
+ [Login and logout](#login-and-logout).
376
404
  - **`destroy()`** removes listeners and timers, then flushes.
377
405
 
378
406
  ### Errors
@@ -516,7 +544,19 @@ so a batch already in flight when the user navigates away still arrives.
516
544
  from `GET /v1/users/{id}/history`. Merging adds up the decayed scores
517
545
  item by item. Use it only when the snapshot does not already contain
518
546
  this device's events, typically right after login. Otherwise those
519
- events count twice.
547
+ events count twice. The snapshot also holds the anonymous browsing of
548
+ the devices the user logged in on before, this one included when it was
549
+ not `reset()` at logout. Send `getAnonId()` with the login request and
550
+ have your backend ask for `GET /v1/users/{id}/history?anon_id=…`: the
551
+ snapshot then leaves out this device's own browsing.
552
+
553
+ ```js
554
+ // Browser, at login:
555
+ const res = await fetch('/login', { method: 'POST', body: JSON.stringify({ ...form, anonId: recapi.getAnonId() }) });
556
+ recapi.identify(userId, { history: (await res.json()).recapiHistory });
557
+ // Backend, with the secret key:
558
+ // GET /v1/users/{userId}/history?anon_id={anonId} (leave anon_id out when it is null)
559
+ ```
520
560
  - Merge rule for the recent lists: take the union of both lists and order
521
561
  it by interaction time, oldest first. An item in both lists takes its
522
562
  latest time. Then keep the last 20.
@@ -613,6 +653,14 @@ minifier, to keep zero dev dependencies.
613
653
 
614
654
  ## Changes
615
655
 
656
+ - 2026-11-01: version 0.6.0. `getAnonId()` returns the device's anonymous
657
+ id, for the backend to fetch the login snapshot without this device's own
658
+ browsing (`?anon_id=`), which would otherwise count twice on a device not
659
+ reset at logout.
660
+ - 2026-11-01: version 0.5.1. Without consent, and under Do Not Track, the
661
+ SDK no longer touches `localStorage` at all: before, it wrote and removed a
662
+ probe key when the client was created, to check that storage works. Now
663
+ that check waits for the first read or write, which only consent allows.
616
664
  - 2026-11-01: version 0.5.0. Do Not Track and Global Privacy Control are
617
665
  respected by default: the SDK then stores nothing and sends no events or
618
666
  visitor ids, and still asks for recommendations
package/index.d.ts CHANGED
@@ -104,7 +104,8 @@ export interface RecommendParams {
104
104
 
105
105
  /** An item's reason code: map the ones you want to your own label, show none for an unknown one. */
106
106
  export type ReasonCode =
107
- | 'for_you' | 'similar' | 'next' | 'trending' | 'popular' | 'new' | 'explore' | 'more' | (string & {});
107
+ | 'for_you' | 'similar' | 'next' | 'bought_together' | 'trending' | 'popular' | 'new' | 'explore' | 'more'
108
+ | (string & {});
108
109
 
109
110
  /** The object stored with the item (`PUT /v1/items`, `metadata`): name, picture, link, price... */
110
111
  export type ItemMetadata = Record<string, unknown>;
@@ -159,6 +160,8 @@ export interface RecapiClient {
159
160
  /** Never rejects; failures go to `onError`. */
160
161
  flush(): Promise<void>;
161
162
  getHistory(): WireHistory;
163
+ /** This device's anonymous id; null without consent or under Do Not Track. */
164
+ getAnonId(): string | null;
162
165
  destroy(): Promise<void>;
163
166
  }
164
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recapi/client",
3
- "version": "0.5.0",
3
+ "version": "0.6.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
@@ -121,8 +121,16 @@ export function createClient(options) {
121
121
  }
122
122
  };
123
123
  const transport = createTransport({ baseUrl, apiKey, fetch: fetchImpl, timeoutMs });
124
+ // The default adapter probes localStorage with a write, so it is picked at
125
+ // the first storage access: never before consent, nor under Do Not Track.
126
+ let adapter = options.storage;
127
+ const storage = () => (adapter ??= defaultStorage(report));
124
128
  const store = createStore(
125
- options.storage ?? defaultStorage(report),
129
+ {
130
+ getItem: (key) => storage().getItem(key),
131
+ setItem: (key, value) => storage().setItem(key, value),
132
+ removeItem: (key) => storage().removeItem(key)
133
+ },
126
134
  storageKey(options.project ?? apiKey.slice(0, 16)),
127
135
  decodePersisted,
128
136
  report
@@ -500,7 +508,8 @@ export function createClient(options) {
500
508
  * `options.history` takes a server snapshot (from
501
509
  * `GET /v1/users/{id}/history`, fetched by your backend with the secret
502
510
  * key) and merges it into the local history. Only pass a snapshot that
503
- * does not already contain this device's events, or they count twice.
511
+ * does not already contain this device's events, or they count twice:
512
+ * fetch it with this device's `getAnonId()`.
504
513
  * Does nothing under Do Not Track: requests name nobody.
505
514
  *
506
515
  * @param {string} id
@@ -580,6 +589,20 @@ export function createClient(options) {
580
589
  return toWire(history, now(), config);
581
590
  },
582
591
 
592
+ /**
593
+ * This device's anonymous id, the `anon_id` its events carry: null
594
+ * without consent or under Do Not Track. Send it with the login request,
595
+ * so your backend asks `GET /v1/users/{id}/history?anon_id=…` for the
596
+ * snapshot it passes to `identify()`: that snapshot then leaves out this
597
+ * device's browsing, which the local history holds already.
598
+ *
599
+ * @returns {string | null}
600
+ */
601
+ getAnonId() {
602
+ if (consent) reload();
603
+ return anonId;
604
+ },
605
+
583
606
  /**
584
607
  * Removes listeners and timers, then flushes what is queued.
585
608
  *