@recapi/client 0.4.0 → 0.5.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.4.0/index.js';
19
+ import { createClient } from 'https://api.example.com/sdk/0.5.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
@@ -103,7 +103,15 @@ in two places, both shown with your address in the dashboard's quick start:
103
103
  so they are unchanged. Each card shows the picture (resized by Shopify's
104
104
  CDN with `?width=400`), the title and `price_min` formatted with
105
105
  `Intl.NumberFormat` in `Shopify.currency.active` and the page's `lang`
106
- (no price if either is missing). A default style makes them a product row
106
+ (no price if either is missing). An item whose
107
+ [metadata](../../docs/api.md#item-metadata) has a `name` and an http(s)
108
+ `url` skips that lookup: its card is drawn from `name`, `url`, `picture`
109
+ (http(s) only) and `price` (text shown as it is, or a number in the
110
+ shop's currency, `{{ shop.currency }}`, shown only to visitors browsing in
111
+ it; others get Shopify's converted price from the lookup). A row whose
112
+ items all carry metadata then costs one request instead of seven. The
113
+ snippet can't send metadata: the shop sends it with `PUT /v1/items`, and
114
+ marks a sold-out product inactive there, since no lookup checks it. A default style makes them a product row
107
115
  in the theme's colours and fonts; all its selectors sit inside
108
116
  `:where(#recapi-recommendations …)`, which has no specificity, so any of
109
117
  the theme's rules overrides it. It follows the shop's cookie banner through
@@ -224,7 +232,11 @@ shortcode shows none unless given one: `[recapi heading="You may also like"]`.
224
232
  The widget fills it like the Shopify snippet's, once it nears the screen,
225
233
  loading the 6 recommended products in one request to WooCommerce's public Store API
226
234
  (`/wc/store/v1/products?include=…`) and skipping those not purchasable or
227
- out of stock. Consent comes from the
235
+ out of stock. Items whose [metadata](../../docs/api.md#item-metadata)
236
+ has a `name` and an http(s) `url` are left out of that request and drawn
237
+ from it, as in the Shopify snippet (a number `price` is in the shop's
238
+ currency, `woocommerce_currency`, and shown only while the visitor browses
239
+ in it), so a row whose items all carry metadata needs no Store API call. Consent comes from the
228
240
  [WP Consent API](https://wordpress.org/plugins/wp-consent-api/)
229
241
  (`wp_has_consent('statistics')`, `wp_listen_for_consent_change`), which
230
242
  banner plugins feed and which reports allowed where no banner manages
@@ -256,7 +268,10 @@ The Magento 2 module ([`sdk/php/magento`](../php/magento/README.md)) prints
256
268
  the SDK on every storefront page for views and add-to-carts, and sends
257
269
  orders from the server with the PHP library. With consent, its script
258
270
  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.
271
+ cookie so the server's purchases are credited like the SDK's own. On product
272
+ pages it fills recapi's row, drawing each card from the item's `metadata`
273
+ or, without it, from the store; the row is printed with the store's own
274
+ products, which stay while recapi has none.
260
275
 
261
276
  ### Node
262
277
 
@@ -310,7 +325,7 @@ for (const item of items) {
310
325
  | `timeoutMs` | `5000` | per-request timeout; `recommend()` waits 1.5 s at most |
311
326
  | `historyConfig` | spec defaults | `{ halfLifeSeconds, topK, recentSize }`; must match the server |
312
327
  | `consent` | `true` | storage consent, see [Consent](#consent-and-eprivacy) |
313
- | `respectDoNotTrack` | `false` | start without consent when DNT or Global Privacy Control is on |
328
+ | `respectDoNotTrack` | `true` | when DNT or Global Privacy Control is on: store nothing, send no events, see [Do Not Track](#do-not-track) |
314
329
  | `onError` | – | gets errors from background work (flushes, storage, rejected events) and those `recommend()` fell back from |
315
330
  | `now`, `fetch`, `sleep` | `Date.now`, global `fetch`, `setTimeout` | for tests |
316
331
 
@@ -550,8 +565,21 @@ if (rec !== null) {
550
565
  Events are still sent, without `anon_id`. Don't call `track()` if you
551
566
  need no network at all. Withdrawing consent deletes the stored data.
552
567
  Granting it merges the in-memory history into storage.
553
- `respectDoNotTrack: true` starts without consent when the browser sends
554
- DNT or Global Privacy Control.
568
+
569
+ ### Do Not Track
570
+
571
+ When the browser sends Do Not Track (`navigator.doNotTrack === '1'`) or
572
+ Global Privacy Control (`navigator.globalPrivacyControl === true`), the
573
+ SDK follows nobody: it reads and writes no storage, `track()` and
574
+ `identify()` do nothing, and `setConsent(true)` doesn't lift it (the
575
+ browser's setting is the visitor's standing answer). `recommend()` and
576
+ `popular()` still work: the request carries no visitor id and no history,
577
+ so the list is popular, trending or similar items (with `contextItemId`).
578
+ The Shopify and WooCommerce snippets and Magento's storefront script
579
+ build on the SDK and behave the same (Magento's order observer runs on the
580
+ server and still sends the purchase). A site with its own legal basis for tracking those visitors
581
+ passes `respectDoNotTrack: false` and gets the behaviour of 0.4.0 and
582
+ earlier.
555
583
 
556
584
  ## Custom storage
557
585
 
@@ -585,6 +613,12 @@ minifier, to keep zero dev dependencies.
585
613
 
586
614
  ## Changes
587
615
 
616
+ - 2026-11-01: version 0.5.0. Do Not Track and Global Privacy Control are
617
+ respected by default: the SDK then stores nothing and sends no events or
618
+ visitor ids, and still asks for recommendations
619
+ ([Do Not Track](#do-not-track)). Before, `respectDoNotTrack` was off by
620
+ default and only stopped storage. Snippets pasted with an older version
621
+ get it too: older versions' paths redirect to the current one.
588
622
  - 2026-10-31: version 0.4.0. Recommended and popular items carry `why`,
589
623
  a reason code you can turn into a label for visitors
590
624
  ([Showing why an item is listed](#showing-why-an-item-is-listed)).
package/index.d.ts CHANGED
@@ -78,7 +78,10 @@ export interface ClientOptions {
78
78
  historyConfig?: Partial<HistoryConfig>;
79
79
  /** Initial storage consent, default `true`. */
80
80
  consent?: boolean;
81
- /** Start without consent when Do Not Track / Global Privacy Control is on. */
81
+ /**
82
+ * Default `true`: when Do Not Track / Global Privacy Control is on, store
83
+ * nothing, send no events and no visitor ids; recommendations still work.
84
+ */
82
85
  respectDoNotTrack?: boolean;
83
86
  /** Receives errors from background work (flushes, storage) and those `recommend()` fell back from. */
84
87
  onError?: (error: unknown) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recapi/client",
3
- "version": "0.4.0",
3
+ "version": "0.5.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
@@ -80,8 +80,9 @@ const PUBLIC_KEY_PREFIX = 'rk_pub_';
80
80
  * @property {number} [timeoutMs] Per-request timeout.
81
81
  * @property {Partial<HistoryConfig>} [historyConfig]
82
82
  * @property {boolean} [consent] Initial storage consent (default `true`).
83
- * @property {boolean} [respectDoNotTrack] Start without consent when the
84
- * browser sends Do Not Track or Global Privacy Control.
83
+ * @property {boolean} [respectDoNotTrack] When the browser sends Do Not
84
+ * Track or Global Privacy Control (default `true`): store nothing, send no
85
+ * events and no visitor ids; recommendations are still asked for.
85
86
  * @property {(error: unknown) => void} [onError] Receives background errors,
86
87
  * and the errors `recommend()` fell back from.
87
88
  * @property {() => number} [now] Clock in unix ms.
@@ -127,8 +128,14 @@ export function createClient(options) {
127
128
  report
128
129
  );
129
130
 
130
- let consent = options.consent ?? true;
131
- if (options.respectDoNotTrack && doNotTrackEnabled()) consent = false;
131
+ // DNT and GPC ask sites not to follow the visitor, and the homepage
132
+ // promises our script listens: such a page sends recapi nothing about them
133
+ // and keeps nothing on the device, so its lists are popular or similar
134
+ // items only. A banner's consent doesn't lift it: the browser's answer is
135
+ // the visitor's standing one. `respectDoNotTrack: false` is for a site with
136
+ // its own legal basis.
137
+ const quiet = (options.respectDoNotTrack ?? true) && doNotTrackEnabled();
138
+ let consent = !quiet && (options.consent ?? true);
132
139
 
133
140
  /** @type {HistoryState} */
134
141
  let history = emptyHistory();
@@ -400,6 +407,7 @@ export function createClient(options) {
400
407
  * `reject` is -10). A negative level is an explicit rejection ("not
401
408
  * interested"): the item is never recommended to this user again until
402
409
  * a positive event on it. Don't send skips or bounces as rejections.
410
+ * Does nothing under Do Not Track (see `respectDoNotTrack`).
403
411
  * @param {{ recommendationId?: string | null }} [options] Overrides the
404
412
  * automatic attribution. `null` (a fallback list's id) is no override.
405
413
  */
@@ -415,6 +423,7 @@ export function createClient(options) {
415
423
  "level must be an integer between -10 and 10, or 'view', 'cart', 'purchase' or 'reject'"
416
424
  );
417
425
  }
426
+ if (quiet) return;
418
427
  const t = now();
419
428
  let attributed = recommendationId;
420
429
  mutate(() => {
@@ -492,12 +501,14 @@ export function createClient(options) {
492
501
  * `GET /v1/users/{id}/history`, fetched by your backend with the secret
493
502
  * key) and merges it into the local history. Only pass a snapshot that
494
503
  * does not already contain this device's events, or they count twice.
504
+ * Does nothing under Do Not Track: requests name nobody.
495
505
  *
496
506
  * @param {string} id
497
507
  * @param {{ history?: ServerSnapshot }} [options]
498
508
  */
499
509
  identify(id, { history: snapshot } = {}) {
500
510
  if (!isId(id)) throw new RecapiError('invalid_request', 'userId must be 1-256 bytes of UTF-8 without control characters');
511
+ if (quiet) return;
501
512
  mutate(() => {
502
513
  if (userId && userId !== id) {
503
514
  history = emptyHistory();
@@ -531,12 +542,13 @@ export function createClient(options) {
531
542
  * consent nothing is read from or written to storage and no anonymous id
532
543
  * exists; the history lives in memory for the current page only.
533
544
  * Withdrawing deletes the stored data. Granting merges any previously
534
- * stored history with the in-memory one.
545
+ * stored history with the in-memory one. Does nothing under Do Not Track:
546
+ * nothing is stored whatever the banner says.
535
547
  *
536
548
  * @param {boolean} value
537
549
  */
538
550
  setConsent(value) {
539
- if (value === consent) return;
551
+ if (quiet || value === consent) return;
540
552
  consent = Boolean(value);
541
553
  if (!consent) {
542
554
  store.remove();