@usethrottle/catalog 1.2.0 → 1.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +111 -15
  3. package/dist/admin.cjs +16 -16
  4. package/dist/admin.d.cts +15 -2
  5. package/dist/admin.d.ts +15 -2
  6. package/dist/admin.js +1 -1
  7. package/dist/{catalog-error-yTBMAdCv.d.cts → catalog-error-BKN5hNWP.d.cts} +11 -1
  8. package/dist/{catalog-error-7DCsTyB2.d.ts → catalog-error-BnabMMEy.d.ts} +11 -1
  9. package/dist/{chunk-JNBKKAMP.js → chunk-3FX2HISB.js} +97 -2
  10. package/dist/{chunk-B2DYAHNR.cjs → chunk-J2GHGQY6.cjs} +334 -43
  11. package/dist/{chunk-TDMDOHD3.cjs → chunk-OKWXS3FD.cjs} +110 -15
  12. package/dist/chunk-PZ3SFYH4.cjs +192 -0
  13. package/dist/chunk-SN6STYPW.js +192 -0
  14. package/dist/{chunk-55G4ZTD5.js → chunk-VJHA7J7B.js} +315 -24
  15. package/dist/{client-Dm6I4nAZ.d.ts → client-B6CsU_v9.d.ts} +1 -1
  16. package/dist/{client-BrHJCyBP.d.cts → client-terkEl6u.d.cts} +1 -1
  17. package/dist/{contract-types-BPSzY_Co.d.cts → contract-types-D2-SSPM9.d.cts} +241 -11
  18. package/dist/{contract-types-BPSzY_Co.d.ts → contract-types-D2-SSPM9.d.ts} +241 -11
  19. package/dist/index.cjs +34 -2
  20. package/dist/index.d.cts +56 -7
  21. package/dist/index.d.ts +56 -7
  22. package/dist/index.js +35 -3
  23. package/dist/next.cjs +180 -0
  24. package/dist/next.d.cts +60 -0
  25. package/dist/next.d.ts +60 -0
  26. package/dist/next.js +180 -0
  27. package/dist/react-extras.cjs +27 -9
  28. package/dist/react-extras.d.cts +7 -4
  29. package/dist/react-extras.d.ts +7 -4
  30. package/dist/react-extras.js +20 -2
  31. package/dist/react.cjs +130 -54
  32. package/dist/react.d.cts +52 -11
  33. package/dist/react.d.ts +52 -11
  34. package/dist/react.js +112 -36
  35. package/dist/server.cjs +58 -66
  36. package/dist/server.d.cts +58 -10
  37. package/dist/server.d.ts +58 -10
  38. package/dist/server.js +52 -60
  39. package/dist/{types-BlYCKzB8.d.ts → types-jNpOwEUw.d.cts} +81 -8
  40. package/dist/{types-BTrqSprj.d.cts → types-z57bkjjB.d.ts} +81 -8
  41. package/dist/url.d.cts +2 -1
  42. package/dist/url.d.ts +2 -1
  43. package/dist/{use-query-QOEBuYlp.d.ts → use-query-COqRZJnC.d.ts} +1 -1
  44. package/dist/{use-query-BZgjxGPO.d.cts → use-query-H1xgL3_e.d.cts} +1 -1
  45. package/package.json +17 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @usethrottle/catalog
2
2
 
3
+ ## 1.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c9636d7: Dynamic storefront. Everything is additive except the cache defaults below.
8
+
9
+ - `client.settings()` returns the store's theme, navigation, home page, strings and route patterns with `settingsVersion` and `generation`. `t(settings, key, vars)`, `resolveMenuTarget(settings, target)`, `DEFAULT_STRINGS` and `fontStylesheetHref` read them.
10
+ - `client.pages.list()` and `client.pages.get(handle)` read the store's content pages.
11
+ - `createServerCatalogClient({ cachePolicy })` sets how long each kind of read is cached (`'until-changed'`, `'no-store'` or a duration such as `'90s'`), with `DEFAULT_CACHE_POLICY`, `cacheKindForPath` and `policyToNext` exported from `/server`. Every read is tagged by family (`throttle-catalog:config`, `:pages`, `:data`) as well as by route and handle, and `scopesToTags(scopes)` turns the scopes of a change notice into the tags to clear.
12
+ - New entry `@usethrottle/catalog/next` for the Next.js App Router (`next` 14 or later, an optional peer): `createRevalidateHandler` verifies the signed `storefront.changed` webhook and revalidates the matching tags; the helpers `previewMiddleware` and `getPreviewToken` carry a dashboard preview token in an HTTP-only cookie; `noteMinGeneration` makes the next `/config` read at least that fresh.
13
+ - `previewToken` client option and a per-call `minGeneration` on settings and pages reads.
14
+ - React: `useStorefrontSettings` and `useGenerationWatch` (`/react`), `usePages` and `usePage` (`/react/extras`).
15
+
16
+ Behaviour changes: the default `revalidate` of `/config`, settings, pages and product reads is now 3600 seconds (it was 60), cleared sooner by tag revalidation; lists, filters, related products, reviews and search keep 60 seconds. Set `cachePolicy` to restore the old numbers. `x-storefront-preview` is a reserved header: the SDK sets it from `previewToken`.
17
+
18
+ ## 1.3.0
19
+
20
+ ### Minor Changes
21
+
22
+ - e159fc9: Multi-currency slice 2. Everything is additive: without the new calls the SDK behaves as 1.2.0.
23
+
24
+ - Money helpers from `@usethrottle/money` (a new dependency, `^1.0.0`): the `money` namespace and, by name, `formatMoney`, `currencySymbol`, `toDecimalString`, `toMinor`, `parseMoney`, `minorUnitsOf`, `isKnownCurrency`, `moneyValue`, `CURRENCIES`, `UnknownCurrencyError` and the `CurrencyCode` and `MoneyValue` types. Prices are integers in the minor units of their currency; format them with `formatMoney`.
25
+ - `CurrencyCode` typing on every price object's `currency` (product, variant, price list and cart line), `CartView.currency` and `OpenCartRef.currency`. `CartOptions.currency` stays `string`.
26
+ - `useCart().format(minor)` formats an amount in the cart's currency.
27
+ - `useCart().switchCurrency(next)` finds or creates the shopper's cart in `next` and moves the current cart's lines into it through Throttle's absorb route. Signed in, it finds the customer's open cart in that currency; as a guest, it reads the optional `CartStorage` per-currency slots (`getFor`, `setFor`, `removeFor`, implemented by `createMemoryCartStorage`). With no target it creates an empty cart in `next`. Removed lines and a dropped discount are reported through the hook's `warnings`. Any failure keeps the old cart, its stored id and its currency.
28
+ - The catalog Worker now accepts an empty cart create (`items: []`), which the switch needs. Older clients are unaffected.
29
+
3
30
  ## 1.2.0
4
31
 
5
32
  ### Minor Changes
package/README.md CHANGED
@@ -32,18 +32,19 @@ for (const product of products) {
32
32
  }
33
33
  ```
34
34
 
35
- Prices are integers in minor units (cents for USD). The client talks to `https://catalog.usethrottle.dev` by default (`baseUrl` changes it). Every call takes `{ signal, timeoutMs }`; the default timeout is 10 seconds.
35
+ Prices are integers in the minor units of their currency; format them with `formatMoney` (`formatMoney(product.priceRange.minMinor, product.priceRange.currency)` gives `$19.99` for USD and `¥1,999` for JPY, the same on the server and in the browser). The root re-exports `@usethrottle/money` as `money` and by name (`formatMoney`, `currencySymbol`, `minorUnitsOf`, `toDecimalString`, `toMinor`, `parseMoney`, `moneyValue`, `CURRENCIES`, `isKnownCurrency`, `UnknownCurrencyError`, and the types `CurrencyCode` and `MoneyValue`); every `currency` the SDK answers is a `CurrencyCode`, which is still assignable to `string`. The client talks to `https://catalog.usethrottle.dev` by default (`baseUrl` changes it). Every call takes `{ signal, timeoutMs }`; the default timeout is 10 seconds.
36
36
 
37
37
  ## Entries
38
38
 
39
- | Import | What it holds |
40
- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
41
- | `@usethrottle/catalog` | `createCatalogClient`, the error classes and `isCatalogError`, and the answer types. No DOM and no Node-only API. |
42
- | `@usethrottle/catalog/react` | `CatalogProvider`, the read hooks, `useCart`, `useSubmitReview` and `useCustomerKey` (marked `"use client"`). |
43
- | `@usethrottle/catalog/react/extras` | `useTags`, `useCollections` and `useReviewEligibility` (marked `"use client"`). They share `CatalogProvider` and the cache with `/react`: import the provider from `/react` and these three from here. |
44
- | `@usethrottle/catalog/server` | `createServerCatalogClient`, `catalogTags`, `syncCatalogGeneration` and the paging helpers (`listAllProductHandles`, `staticProductParams`, `listAllCollectionHandles`, `listAllCategoryHandles`: it asks for `CATEGORY_TREE_MAX_DEPTH` levels, 10, so a sitemap lists the whole tree). |
45
- | `@usethrottle/catalog/url` | `parseShopQuery`, `serializeShopQuery`, `toProductsQuery`, `parseRange` and `formatRange`. Standalone: none of the client or React code (the ESM and CommonJS builds each load one tiny shared helper file). |
46
- | `@usethrottle/catalog/admin` | **Experimental, server only.** `createCatalogAdmin` and `verifyCatalogWebhook` for the catalog push API (sync jobs, single batches, runs, products, stock), and the push request and answer types. See [Admin](#admin-server-only-experimental). |
39
+ | Import | What it holds |
40
+ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
41
+ | `@usethrottle/catalog` | `createCatalogClient`, the error classes and `isCatalogError`, the answer types, and the `@usethrottle/money` helpers (`money`, `formatMoney`, …). No DOM and no Node-only API. |
42
+ | `@usethrottle/catalog/react` | `CatalogProvider`, the read hooks, `useCart`, `useSubmitReview` and `useCustomerKey` (marked `"use client"`). |
43
+ | `@usethrottle/catalog/react/extras` | `useTags`, `useCollections` and `useReviewEligibility` (marked `"use client"`). They share `CatalogProvider` and the cache with `/react`: import the provider from `/react` and these three from here. |
44
+ | `@usethrottle/catalog/server` | `createServerCatalogClient`, `catalogTags`, `syncCatalogGeneration` and the paging helpers (`listAllProductHandles`, `staticProductParams`, `listAllCollectionHandles`, `listAllCategoryHandles`: it asks for `CATEGORY_TREE_MAX_DEPTH` levels, 10, so a sitemap lists the whole tree). |
45
+ | `@usethrottle/catalog/next` | **Server only.** `createRevalidateHandler` (the change-notice webhook), `previewMiddleware`, `getPreviewToken` and `noteMinGeneration` for the Next.js App Router. `next` (14 or later) is an optional peer. See [Revalidation webhook](#revalidation-webhook) and [Preview mode](#preview-mode). |
46
+ | `@usethrottle/catalog/url` | `parseShopQuery`, `serializeShopQuery`, `toProductsQuery`, `parseRange` and `formatRange`. Standalone: none of the client or React code (the ESM and CommonJS builds each load one tiny shared helper file). |
47
+ | `@usethrottle/catalog/admin` | **Experimental, server only.** `createCatalogAdmin` and `verifyCatalogWebhook` for the catalog push API (sync jobs, single batches, runs, products, stock), and the push request and answer types. See [Admin](#admin-server-only-experimental). |
47
48
 
48
49
  Everything `/react` exports is a client reference in a server component, `SDK_VERSION` and `catalogQueryKey` included: in server code import `SDK_VERSION` from the root or `/server`, and call `catalogQueryKey` only in client code.
49
50
 
@@ -105,7 +106,7 @@ try {
105
106
 
106
107
  ## Carts and checkout
107
108
 
108
- Carts are resolved by the catalog: the browser sends SKUs and quantities, never prices. `carts.create` answers Throttle's hosted cart URL (`cartUrl`) and a session id. It takes three optional URLs, each an absolute `https` URL on the store's Checkout settings › Allowed return websites (otherwise `422 RETURN_URL_NOT_ALLOWED`, one issue per refused field):
109
+ Carts are resolved by the catalog: the browser sends SKUs and quantities, never prices. `carts.create` answers Throttle's hosted cart URL (`cartUrl`) and a session id; `items` may be empty, for an empty cart in a currency. It takes three optional URLs, each an absolute `https` URL on the store's Checkout settings › Allowed return websites (otherwise `422 RETURN_URL_NOT_ALLOWED`, one issue per refused field):
109
110
 
110
111
  ```ts
111
112
  const cart = await catalog.carts.create({
@@ -117,7 +118,7 @@ const cart = await catalog.carts.create({
117
118
  // cart.returnUrlApplied is false when Throttle did not store them.
118
119
  ```
119
120
 
120
- Without them the shopper ends on Throttle's own completion page. To build your own cart page instead, use `catalog.carts.session(cart.sessionId)` (`get`, `add`, `update`, `remove`, `refresh`, `checkout({ returnUrl, cancelUrl })`). `checkout()` answers the checkout URL; it never navigates. Both are checked before any request: `carts.create` refuses a URL that is not absolute `https` (`INVALID_REQUEST`, naming the field), and `checkout()` one that is not absolute `http` or `https`.
121
+ Without them the shopper ends on Throttle's own completion page. To build your own cart page instead, use `catalog.carts.session(cart.sessionId)` (`get`, `add`, `update`, `remove`, `refresh`, `checkout({ returnUrl, cancelUrl })`, and `absorb(fromSessionId)`, which moves another session's cart into this one in this cart's currency, answering `{ cart, warnings }`; `useCart().switchCurrency` is built on it). `checkout()` answers the checkout URL; it never navigates. Both are checked before any request: `carts.create` refuses a URL that is not absolute `https` (`INVALID_REQUEST`, naming the field), and `checkout()` one that is not absolute `http` or `https`.
121
122
 
122
123
  ## React
123
124
 
@@ -191,7 +192,7 @@ function ProductGrid() {
191
192
  - **Hooks:** `useProducts`, `useProduct`, `useRelated`, `useFilterValues`, `useCategories`, `useCollection`, `useSuggest` (debounced 200 ms, 2 characters at least), `useReviews` and `useConfig` from `/react`; `useTags`, `useCollections` and `useReviewEligibility` from `/react/extras` (below). Each answers `{ data, dataFor, error, isLoading, isPreviousData, isStale, isValidating, refetch }` and takes `enabled`, `initialData` (for an SSR hand-off), `staleTimeMs` and `keepPreviousData`. With a signed-in customer (`customerKey` set), `initialData` is shown and revalidated at once, since a server render with the server key is anonymous; to avoid the brief list-price paint, render the server side with the customer's token.
192
193
  - **The cache** is per provider: answers are shown at once and refetched when older than 60 seconds, and calls for one key share one request. Two providers never share it.
193
194
  - **`customerKey`** partitions the cache by customer. Change it in the same update as the token: a change starts an empty cache and every mounted hook refetches, so one customer never sees another's prices. It is required whenever `customerToken` can return a token: `useCart` drops the cart on a sign-out or a customer change only by watching it, so without it one customer's cart stays for the next shopper. Keep one `client` per currency and language (see [One client per currency and language](#one-client-per-currency-and-language)) and pass `customerToken` as a function.
194
- - **`useCart`** keeps one cart per provider and runs its changes one at a time. It creates the cart on the first `add`, recreates it once if Throttle ended the session, and makes a new cart on `switchCurrency`. A switch lands whole or not at all: if the new cart cannot be made, read back or saved, `switchCurrency` rejects and the old cart, its stored id and the old currency stay. The cart is dropped when the signed-in customer signs out or changes, and kept on sign-in. `checkout()` uses the provider's `cartUrls` unless given URLs, and answers the checkout URL. `cartUrls` go on every cart create, so they must be absolute `https` URLs on the store's Allowed return websites, or every `add` fails; otherwise (on `http://localhost`, say) leave them out and pass the URLs to `checkout(input)`. The default `cartStorage` is memory only; key a persistent one by customer, as above: a new adapter object per customer, never one stable object that reads the current customer when called (a drop removes the outgoing customer's entry through the outgoing object, so such an adapter would delete the incoming customer's cart instead).
195
+ - **`useCart`** keeps one cart per provider and runs its changes one at a time. It creates the cart on the first `add` and recreates it once if Throttle ended the session. `format(minor)` formats an amount in the cart's currency (`useCart().format(4763)` on a EUR cart is `€47.63`). **`switchCurrency(currency)`** keeps one cart per currency: it moves the cart into the shopper's open cart in that currency (a signed-in customer's from `customerCart`; a guest's from `cartStorage.getFor`, used only while that cart is still open), or into a new empty cart when there is none, and Throttle closes the old cart as merged. Lines with no price in the new currency stay behind and a discount code the new currency refuses is dropped; both are reported in `warnings` (`lines_removed`, `discount_dropped`). If anything fails before the move lands (the lookup, the new cart, the move itself, for example `422 currency_not_available_in_production`), `switchCurrency` rejects and the old cart, its stored id and the old currency stay. A signed-in switch needs `customerToken` on the client: the new cart is created as the customer's, and Throttle moves a customer's cart only into that customer's cart (`403 cart_owner_mismatch` otherwise). A `cartStorage` adapter may implement the optional `getFor(currency)`, `setFor(currency, id)` and `removeFor(currency)` (`createMemoryCartStorage` does); without them a guest switch always makes a new cart. These are guest slots: a signed-in switch never writes them, and the provider forgets them on a sign-in, a sign-out or a customer change (through `removeFor`, or `setFor(currency, '')` when the adapter has no `removeFor`), so the next guest never reaches a customer's cart. The cart is dropped when the signed-in customer signs out or changes, and kept on sign-in. `checkout()` uses the provider's `cartUrls` unless given URLs, and answers the checkout URL. `cartUrls` go on every cart create, so they must be absolute `https` URLs on the store's Allowed return websites, or every `add` fails; otherwise (on `http://localhost`, say) leave them out and pass the URLs to `checkout(input)`. The default `cartStorage` is memory only; key a persistent one by customer, as above: a new adapter object per customer, never one stable object that reads the current customer when called (a drop removes the outgoing customer's entry through the outgoing object, so such an adapter would delete the incoming customer's cart instead).
195
196
  - **Rate limits:** a catalog `429` pauses every read of the provider until its retry time; each mounted key then refetches once.
196
197
  - **`useSubmitReview(handle)`** submits a review with one idempotency key per submission.
197
198
 
@@ -229,7 +230,7 @@ function Shell({ customerId, children }) {
229
230
  }
230
231
  ```
231
232
 
232
- `useCart` looks the cart up once per visit when the slot is empty (or ended), and before it would create a new cart. **It resumes only when a currency is known:** set `currency` on the client (`createCatalogClient({ currency: 'USD' })`) or pass it to the hook, `useCart({ currency: 'USD' })`. With neither, `useCart` makes no lookup and the first `add` creates a cart as before. A found cart in another currency is not adopted. Without `customerCart` nothing changes from 1.1.0. Known limits: a write within 30 seconds of a read that predates another device's merge reopens the emptied cart, and so can a write after a re-read that failed. The lookup runs once per visit, so a currency that becomes known only after mount does not trigger it; the first `add` finds the cart instead. With two `useCart` call sites that pass different currencies, the lookup and the create can use different currencies; pass one currency (or set it on the client). `switchCurrency` while signed in creates a cart in the new currency without a lookup, so a customer with an open cart in that currency on another device can end up with a second one.
233
+ `useCart` looks the cart up once per visit when the slot is empty (or ended), and before it would create a new cart. **It resumes only when a currency is known:** set `currency` on the client (`createCatalogClient({ currency: 'USD' })`) or pass it to the hook, `useCart({ currency: 'USD' })`. With neither, `useCart` makes no lookup and the first `add` creates a cart as before. A found cart in another currency is not adopted. Without `customerCart` nothing changes from 1.1.0. Throttle refuses a write to a cart another device merged (`409 cart_not_open`), so it is never reopened; `useCart` then looks the customer's cart up. `switchCurrency` while signed in looks up the customer's open cart in the new currency and moves the cart into it, so a customer keeps one cart per currency across devices. The lookup runs once per visit, so a currency that becomes known only after mount does not trigger it; the first `add` finds the cart instead. With two `useCart` call sites that pass different currencies, the lookup and the create can use different currencies; pass one currency (or set it on the client).
233
234
 
234
235
  ### Key transport
235
236
 
@@ -312,7 +313,7 @@ function ReviewButton({ handle }: { handle: string }) {
312
313
 
313
314
  ## Next.js
314
315
 
315
- On the server, use a **server** key and `createServerCatalogClient`. Every catalog read's `fetch` carries `next: { revalidate, tags }` (60 seconds by default); carts, Throttle calls and any request with a customer token are `cache: 'no-store'`. Next.js itself is never imported.
316
+ On the server, use a **server** key and `createServerCatalogClient`. Every catalog read's `fetch` carries `next: { revalidate, tags }` (an hour for settings, pages and products, cleared sooner by tags; 60 seconds for lists, filters, related products, reviews and search: see [Cache policy](#cache-policy)); carts, Throttle calls and any request with a customer token are `cache: 'no-store'`. Next.js itself is never imported.
316
317
 
317
318
  ```ts
318
319
  // lib/catalog.ts
@@ -349,6 +350,101 @@ export async function GET() {
349
350
 
350
351
  `staticProductParams(catalog)` feeds `generateStaticParams`, and `listAllProductHandles(catalog)` (an async iterable) feeds a sitemap. In the browser, use a browser key and the React provider.
351
352
 
353
+ ## Storefront settings
354
+
355
+ The store's theme, navigation, home page, strings and route patterns are edited in the dashboard and read with `client.settings()`. It is `/config` cut to `{ settings, settingsVersion, generation }`.
356
+
357
+ ```ts
358
+ import { t, resolveMenuTarget } from '@usethrottle/catalog';
359
+ import { catalog } from '@/lib/catalog'; // createServerCatalogClient(...)
360
+
361
+ const { settings } = await catalog.settings();
362
+ t(settings, 'cart.checkout'); // "Checkout", in the locale of the read; falls back to English
363
+ settings.navigation.header.map((item) => ({
364
+ label: item.label,
365
+ href: resolveMenuTarget(settings, item.target), // null when it points at nothing
366
+ }));
367
+ ```
368
+
369
+ In React, `useStorefrontSettings()` (from `/react`) returns the same answer, and `useGenerationWatch({ onChange })` polls the generation (every 60 seconds by default, only while the tab is visible) and refreshes the cached reads when it moves. Settings and pages calls take `minGeneration`, the generation a change notice named: a read above what the edge holds is answered fresh.
370
+
371
+ ## Pages
372
+
373
+ Pages are the store's own content pages (shipping, about, ...). `catalog.pages.list()` returns `{ pages: [{ handle, title, position }] }` and `catalog.pages.get(handle)` returns `{ handle, title, bodyHtml, seo }`. In React, `usePages()` and `usePage(handle)` come from `/react/extras`.
374
+
375
+ ```ts
376
+ const { bodyHtml, seo } = await catalog.pages.get('shipping');
377
+ ```
378
+
379
+ `bodyHtml` is HTML: render it only where you trust the store's editors, or sanitize it first.
380
+
381
+ ## Cache policy
382
+
383
+ `createServerCatalogClient({ cachePolicy })` sets how long each kind of read is cached. Kinds: `settings`, `navigation`, `pages`, `listings`, `facets`, `product`, `related`, `reviews` and `search`. A policy is `'until-changed'` (an hour's safety TTL, cleared sooner by cache tags), `'no-store'`, or a duration such as `'90s'` or `'5m'`. The defaults are `'until-changed'` for `settings`, `navigation`, `pages` and `product`, and `'60s'` for the rest. Customer-token, cart and preview requests are always `no-store`.
384
+
385
+ ```ts
386
+ export const catalog = createServerCatalogClient({
387
+ publishableKey: process.env.CATALOG_SERVER_KEY ?? '',
388
+ cachePolicy: { product: '5m', search: 'no-store' },
389
+ });
390
+ ```
391
+
392
+ Every read carries its tags: `throttle-catalog`, a family tag (`throttle-catalog:config`, `:pages` or `:data`), the route's own and, for one handle, `throttle-catalog:<route>:<handle>`. `scopesToTags(scopes)` turns the scopes of a change notice into the tags to clear.
393
+
394
+ ## Revalidation webhook
395
+
396
+ Instead of polling, point a catalog webhook at a route handler. The catalog sends `storefront.changed` with the changed scopes; the handler checks the `x-catalog-signature` header, clears the matching tags and makes the next `/config` read fresh. Several secrets can be passed while one is being rotated.
397
+
398
+ ```ts
399
+ // app/api/throttle/revalidate/route.ts
400
+ import { createRevalidateHandler } from '@usethrottle/catalog/next';
401
+
402
+ export const POST = createRevalidateHandler({
403
+ secret: process.env.THROTTLE_WEBHOOK_SECRET,
404
+ });
405
+ ```
406
+
407
+ It answers `200 { revalidated: [...tags] }`, `401` for a missing, wrong or older-than-5-minutes signature, `400` for a body that is not a change notice, `413` for a body over 64 KB and `500` when no secret is set. The secret is read on each request, so an unset variable at build time does not fail `next build`. The route expires the tags immediately (`revalidateTag(tag, { expire: 0 })`), so the next request after a change is rendered fresh rather than stale. Each notice also raises a generation floor: the server client's next `/config` read (and so `settings()`) asks for at least that generation until an answer reaches it. The floor applies to every client of the process unless you name it. With one store that needs nothing. On a server with several stores, a handler without `floorKey` raises the floor for every store, so the other stores' reads skip the cache (`no-store`) until it clears: always pass each store's publishable key. With several stores in one app, give each handler the publishable key of its store's server client (or a `floorKey` set on both), so one store's notice never touches another's reads. If the variable is unset, `floorKey: process.env.X` is `undefined` and falls back to the shared floor.
408
+
409
+ ```ts
410
+ export const POST = createRevalidateHandler({
411
+ secret: process.env.STORE_A_WEBHOOK_SECRET,
412
+ floorKey: process.env.STORE_A_KEY, // the publishableKey of store A's createServerCatalogClient
413
+ });
414
+ ```
415
+
416
+ Pass `revalidateTag` to use your own function instead of the one from `next/cache`.
417
+
418
+ ## Preview mode
419
+
420
+ The dashboard's preview opens your storefront with `?throttle_preview=<token>`. The middleware moves the token into an HTTP-only cookie (`SameSite=None; Secure`, one hour, so it works inside the preview iframe) and redirects to the same URL without it. Pass the token to the server client and settings and pages answer the draft, uncached.
421
+
422
+ ```ts
423
+ // middleware.ts
424
+ import { previewMiddleware } from '@usethrottle/catalog/next';
425
+
426
+ export function middleware(req: Request) {
427
+ return previewMiddleware(req) ?? undefined;
428
+ }
429
+ ```
430
+
431
+ ```ts
432
+ // lib/catalog.ts
433
+ import { createServerCatalogClient } from '@usethrottle/catalog/server';
434
+ import { getPreviewToken } from '@usethrottle/catalog/next';
435
+
436
+ export async function getCatalog() {
437
+ return createServerCatalogClient({
438
+ publishableKey: process.env.CATALOG_SERVER_KEY ?? '',
439
+ previewToken: await getPreviewToken(),
440
+ });
441
+ }
442
+ ```
443
+
444
+ Safari may drop `Secure` cookies on `http://localhost`: develop the preview over https, or use Chrome. With `exactOptionalPropertyTypes` on, pass `previewToken` only when there is one (`...(token ? { previewToken: token } : {})`), since `getPreviewToken()` can return `undefined`.
445
+
446
+ `x-storefront-preview` is a reserved header: the SDK sets it from `previewToken`, and a `headers` option may not.
447
+
352
448
  ## URL helpers
353
449
 
354
450
  `@usethrottle/catalog/url` keeps a shop page's selection in its URL, with one canonical string per selection and every parameter it does not know kept in order:
@@ -444,7 +540,7 @@ The OpenAPI 3.1 document of the storefront API is at https://catalog.usethrottle
444
540
 
445
541
  ### One client per currency and language
446
542
 
447
- Hooks take no per-call currency or language: both come from the client. A store with a currency or language switcher keeps one client per `(currency, locale)`, memoised on both, so the client changes only when one of them does. On a currency switch, run `useCart().switchCurrency()` first and swap the client only once it resolves. The switch lands whole or not at all: when it rejects, the cart is still in the old currency, so keep the old client.
543
+ Hooks take no per-call currency or language: both come from the client. A store with a currency or language switcher keeps one client per `(currency, locale)`, memoised on both, so the client changes only when one of them does. On a currency switch, run `useCart().switchCurrency()` first and swap the client only once it resolves. When it rejects, the cart is still in the old currency, so keep the old client; when it resolves, show `cart.warnings` (lines left behind, a dropped discount code).
448
544
 
449
545
  ```tsx
450
546
  'use client';
package/dist/admin.cjs CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
 
12
12
 
13
- var _chunkB2DYAHNRcjs = require('./chunk-B2DYAHNR.cjs');
13
+ var _chunkJ2GHGQY6cjs = require('./chunk-J2GHGQY6.cjs');
14
14
  require('./chunk-ETV4XYOV.cjs');
15
15
 
16
16
  // src/generated/push-types.ts
@@ -45,7 +45,7 @@ var MAX_IDEMPOTENCY_KEY = 200;
45
45
  var HEADER_SAFE = /^[\x21-\x7e]+$/u;
46
46
  var MAX_RETRY_AFTER_S = 86400;
47
47
  function invalidRequest(message) {
48
- return new (0, _chunkB2DYAHNRcjs.CatalogError)({
48
+ return new (0, _chunkJ2GHGQY6cjs.CatalogError)({
49
49
  code: "INVALID_REQUEST",
50
50
  message,
51
51
  statusCode: 0,
@@ -54,7 +54,7 @@ function invalidRequest(message) {
54
54
  });
55
55
  }
56
56
  function clientError(code, message) {
57
- return new (0, _chunkB2DYAHNRcjs.CatalogError)({
57
+ return new (0, _chunkJ2GHGQY6cjs.CatalogError)({
58
58
  code,
59
59
  message,
60
60
  statusCode: 0,
@@ -126,13 +126,13 @@ function toError(status, headers, text) {
126
126
  requestId
127
127
  };
128
128
  if (status === 429) {
129
- return new (0, _chunkB2DYAHNRcjs.RateLimitedError)({
129
+ return new (0, _chunkJ2GHGQY6cjs.RateLimitedError)({
130
130
  ...init,
131
131
  retryable: true,
132
- retryAfterSeconds: retryAfter !== null && retryAfter >= 1 ? retryAfter : _chunkB2DYAHNRcjs.RATE_LIMIT_DEFAULT_RETRY_S
132
+ retryAfterSeconds: retryAfter !== null && retryAfter >= 1 ? retryAfter : _chunkJ2GHGQY6cjs.RATE_LIMIT_DEFAULT_RETRY_S
133
133
  });
134
134
  }
135
- return new (0, _chunkB2DYAHNRcjs.CatalogError)(init);
135
+ return new (0, _chunkJ2GHGQY6cjs.CatalogError)(init);
136
136
  }
137
137
  function guarded(work, callerSignal, timeoutMs) {
138
138
  if (_optionalChain([callerSignal, 'optionalAccess', _8 => _8.aborted])) return Promise.reject(callerSignal.reason);
@@ -151,7 +151,7 @@ function guarded(work, callerSignal, timeoutMs) {
151
151
  const timer = setTimeout(() => {
152
152
  finish();
153
153
  controller.abort();
154
- reject(new (0, _chunkB2DYAHNRcjs.TimeoutError)({ timeoutMs }));
154
+ reject(new (0, _chunkJ2GHGQY6cjs.TimeoutError)({ timeoutMs }));
155
155
  }, timeoutMs);
156
156
  _optionalChain([callerSignal, 'optionalAccess', _12 => _12.addEventListener, 'call', _13 => _13("abort", onAbort, { once: true })]);
157
157
  work(controller.signal).then(
@@ -194,7 +194,7 @@ function createHttp(key, baseUrl, fetchFn, timeoutMs) {
194
194
  signal
195
195
  });
196
196
  } catch (cause) {
197
- throw new (0, _chunkB2DYAHNRcjs.NetworkError)({ cause });
197
+ throw new (0, _chunkJ2GHGQY6cjs.NetworkError)({ cause });
198
198
  }
199
199
  try {
200
200
  return {
@@ -203,14 +203,14 @@ function createHttp(key, baseUrl, fetchFn, timeoutMs) {
203
203
  text: await response.text()
204
204
  };
205
205
  } catch (cause) {
206
- throw new (0, _chunkB2DYAHNRcjs.NetworkError)({ cause });
206
+ throw new (0, _chunkJ2GHGQY6cjs.NetworkError)({ cause });
207
207
  }
208
208
  },
209
209
  spec.signal,
210
210
  timeoutMs
211
211
  );
212
212
  } catch (error) {
213
- if (error instanceof _chunkB2DYAHNRcjs.NetworkError && attempt < attempts) continue;
213
+ if (error instanceof _chunkJ2GHGQY6cjs.NetworkError && attempt < attempts) continue;
214
214
  throw error;
215
215
  }
216
216
  }
@@ -223,7 +223,7 @@ function createHttp(key, baseUrl, fetchFn, timeoutMs) {
223
223
  throw toError(answer.status, answer.headers, answer.text);
224
224
  const parsed = parseJson(answer.text);
225
225
  if (parsed === void 0)
226
- throw new (0, _chunkB2DYAHNRcjs.CatalogError)({
226
+ throw new (0, _chunkJ2GHGQY6cjs.CatalogError)({
227
227
  code: "INVALID_RESPONSE",
228
228
  message: "The server answered with a body that is not JSON.",
229
229
  statusCode: answer.status,
@@ -278,7 +278,7 @@ function refQuery(ref) {
278
278
  return [[field, value]];
279
279
  }
280
280
  function unreadable() {
281
- return new (0, _chunkB2DYAHNRcjs.CatalogError)({
281
+ return new (0, _chunkJ2GHGQY6cjs.CatalogError)({
282
282
  code: "INVALID_RESPONSE",
283
283
  message: "The server answered with an unexpected shape.",
284
284
  statusCode: 200,
@@ -367,7 +367,7 @@ async function pollUntil(read, done, options = {}) {
367
367
  const value = await read(options.signal);
368
368
  if (done(value)) return value;
369
369
  const remaining = deadline - Date.now();
370
- if (remaining <= 0) throw new (0, _chunkB2DYAHNRcjs.TimeoutError)({ timeoutMs });
370
+ if (remaining <= 0) throw new (0, _chunkJ2GHGQY6cjs.TimeoutError)({ timeoutMs });
371
371
  await sleep(Math.min(intervalMs, remaining), options.signal);
372
372
  }
373
373
  }
@@ -452,7 +452,7 @@ function createJobs(http) {
452
452
  await handle.send(rows, { idempotencyKey });
453
453
  return;
454
454
  } catch (error) {
455
- if (!(error instanceof _chunkB2DYAHNRcjs.RateLimitedError) || retry >= QUEUE_RETRIES)
455
+ if (!(error instanceof _chunkJ2GHGQY6cjs.RateLimitedError) || retry >= QUEUE_RETRIES)
456
456
  throw error;
457
457
  await sleep(error.retryAfterSeconds * 1e3, signal);
458
458
  }
@@ -589,7 +589,7 @@ function assertServer() {
589
589
  function createCatalogAdmin(options) {
590
590
  assertServer();
591
591
  const key = checkKey(options.key);
592
- const baseUrl = (_nullishCoalesce(options.baseUrl, () => ( _chunkB2DYAHNRcjs.DEFAULT_BASE_URL))).replace(/\/+$/u, "");
592
+ const baseUrl = (_nullishCoalesce(options.baseUrl, () => ( _chunkJ2GHGQY6cjs.DEFAULT_BASE_URL))).replace(/\/+$/u, "");
593
593
  if (!/^https?:\/\/[^\s/]+/iu.test(baseUrl))
594
594
  throw invalidRequest("baseUrl must be an absolute http or https URL.");
595
595
  const http = createHttp(
@@ -678,4 +678,4 @@ async function verifyCatalogWebhook(input) {
678
678
 
679
679
 
680
680
 
681
- exports.CartLineError = _chunkB2DYAHNRcjs.CartLineError; exports.CatalogError = _chunkB2DYAHNRcjs.CatalogError; exports.CustomerTokenInvalidError = _chunkB2DYAHNRcjs.CustomerTokenInvalidError; exports.FeatureUnavailableError = _chunkB2DYAHNRcjs.FeatureUnavailableError; exports.NetworkError = _chunkB2DYAHNRcjs.NetworkError; exports.OriginNotAllowedError = _chunkB2DYAHNRcjs.OriginNotAllowedError; exports.RateLimitedError = _chunkB2DYAHNRcjs.RateLimitedError; exports.TimeoutError = _chunkB2DYAHNRcjs.TimeoutError; exports.createCatalogAdmin = createCatalogAdmin; exports.isCatalogError = _chunkB2DYAHNRcjs.isCatalogError; exports.verifyCatalogWebhook = verifyCatalogWebhook;
681
+ exports.CartLineError = _chunkJ2GHGQY6cjs.CartLineError; exports.CatalogError = _chunkJ2GHGQY6cjs.CatalogError; exports.CustomerTokenInvalidError = _chunkJ2GHGQY6cjs.CustomerTokenInvalidError; exports.FeatureUnavailableError = _chunkJ2GHGQY6cjs.FeatureUnavailableError; exports.NetworkError = _chunkJ2GHGQY6cjs.NetworkError; exports.OriginNotAllowedError = _chunkJ2GHGQY6cjs.OriginNotAllowedError; exports.RateLimitedError = _chunkJ2GHGQY6cjs.RateLimitedError; exports.TimeoutError = _chunkJ2GHGQY6cjs.TimeoutError; exports.createCatalogAdmin = createCatalogAdmin; exports.isCatalogError = _chunkJ2GHGQY6cjs.isCatalogError; exports.verifyCatalogWebhook = verifyCatalogWebhook;
package/dist/admin.d.cts CHANGED
@@ -1,5 +1,6 @@
1
- export { C as CartLineError, a as CartLineProblem, b as CatalogError, c as CatalogErrorCode, d as CatalogErrorInit, e as CatalogErrorIssue, f as CatalogErrorSource, g as ClientErrorCode, h as CustomerTokenInvalidError, F as FeatureUnavailableError, N as NetworkError, O as OriginNotAllowedError, R as RateLimitedError, T as TimeoutError, i as isCatalogError } from './catalog-error-yTBMAdCv.cjs';
2
- import './contract-types-BPSzY_Co.cjs';
1
+ export { C as CartLineError, a as CartLineProblem, b as CatalogError, c as CatalogErrorCode, d as CatalogErrorInit, e as CatalogErrorIssue, f as CatalogErrorSource, g as ClientErrorCode, h as CustomerTokenInvalidError, F as FeatureUnavailableError, N as NetworkError, O as OriginNotAllowedError, R as RateLimitedError, T as TimeoutError, i as isCatalogError } from './catalog-error-BKN5hNWP.cjs';
2
+ import './contract-types-D2-SSPM9.cjs';
3
+ import '@usethrottle/money';
3
4
 
4
5
  type JobBatchAnswer = {
5
6
  batchId: string;
@@ -84,6 +85,16 @@ type PushJob = {
84
85
  updated?: number | undefined;
85
86
  unchanged?: number | undefined;
86
87
  reportExpiresAt?: string | null | undefined;
88
+ failureMessage?: string | null | undefined;
89
+ progressUpdatedAt?: string | undefined;
90
+ attempts?: number | undefined;
91
+ lastAttempt?: {
92
+ attempt: number;
93
+ at: string;
94
+ result: 'success' | 'retryable_failure' | 'terminal_failure';
95
+ code: string;
96
+ } | null | undefined;
97
+ nextAttemptAt?: string | null | undefined;
87
98
  batches?: {
88
99
  batchId: string;
89
100
  sequence: number;
@@ -138,6 +149,8 @@ type PushProduct = {
138
149
  held: boolean;
139
150
  apiArchived: boolean;
140
151
  purgeAfter: string | null;
152
+ createdAt: string;
153
+ updatedAt: string;
141
154
  vendorId: string | null;
142
155
  attributes: {
143
156
  [key: string]: JsonValue;
package/dist/admin.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- export { C as CartLineError, a as CartLineProblem, b as CatalogError, c as CatalogErrorCode, d as CatalogErrorInit, e as CatalogErrorIssue, f as CatalogErrorSource, g as ClientErrorCode, h as CustomerTokenInvalidError, F as FeatureUnavailableError, N as NetworkError, O as OriginNotAllowedError, R as RateLimitedError, T as TimeoutError, i as isCatalogError } from './catalog-error-7DCsTyB2.js';
2
- import './contract-types-BPSzY_Co.js';
1
+ export { C as CartLineError, a as CartLineProblem, b as CatalogError, c as CatalogErrorCode, d as CatalogErrorInit, e as CatalogErrorIssue, f as CatalogErrorSource, g as ClientErrorCode, h as CustomerTokenInvalidError, F as FeatureUnavailableError, N as NetworkError, O as OriginNotAllowedError, R as RateLimitedError, T as TimeoutError, i as isCatalogError } from './catalog-error-BnabMMEy.js';
2
+ import './contract-types-D2-SSPM9.js';
3
+ import '@usethrottle/money';
3
4
 
4
5
  type JobBatchAnswer = {
5
6
  batchId: string;
@@ -84,6 +85,16 @@ type PushJob = {
84
85
  updated?: number | undefined;
85
86
  unchanged?: number | undefined;
86
87
  reportExpiresAt?: string | null | undefined;
88
+ failureMessage?: string | null | undefined;
89
+ progressUpdatedAt?: string | undefined;
90
+ attempts?: number | undefined;
91
+ lastAttempt?: {
92
+ attempt: number;
93
+ at: string;
94
+ result: 'success' | 'retryable_failure' | 'terminal_failure';
95
+ code: string;
96
+ } | null | undefined;
97
+ nextAttemptAt?: string | null | undefined;
87
98
  batches?: {
88
99
  batchId: string;
89
100
  sequence: number;
@@ -138,6 +149,8 @@ type PushProduct = {
138
149
  held: boolean;
139
150
  apiArchived: boolean;
140
151
  purgeAfter: string | null;
152
+ createdAt: string;
153
+ updatedAt: string;
141
154
  vendorId: string | null;
142
155
  attributes: {
143
156
  [key: string]: JsonValue;
package/dist/admin.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  RateLimitedError,
11
11
  TimeoutError,
12
12
  isCatalogError
13
- } from "./chunk-55G4ZTD5.js";
13
+ } from "./chunk-VJHA7J7B.js";
14
14
  import "./chunk-PKBMQBKP.js";
15
15
 
16
16
  // src/generated/push-types.ts
@@ -1,4 +1,4 @@
1
- import { i as CatalogContractErrorCode } from './contract-types-BPSzY_Co.cjs';
1
+ import { k as CatalogContractErrorCode } from './contract-types-D2-SSPM9.cjs';
2
2
 
3
3
  /**
4
4
  * Shared error type for every Throttle SDK.
@@ -153,6 +153,16 @@ declare const THROTTLE_CART_CODES: {
153
153
  readonly statuses: readonly [501];
154
154
  readonly retryable: false;
155
155
  };
156
+ /** The absorb (MC2-R39): a customer's cart never moves into a guest's or another customer's. */
157
+ readonly cart_owner_mismatch: {
158
+ readonly statuses: readonly [403];
159
+ readonly retryable: false;
160
+ };
161
+ /** The absorb: the two carts are priced by different apps. */
162
+ readonly cart_resolver_mismatch: {
163
+ readonly statuses: readonly [422];
164
+ readonly retryable: false;
165
+ };
156
166
  };
157
167
  type ThrottleCartCode = keyof typeof THROTTLE_CART_CODES;
158
168
 
@@ -1,4 +1,4 @@
1
- import { i as CatalogContractErrorCode } from './contract-types-BPSzY_Co.js';
1
+ import { k as CatalogContractErrorCode } from './contract-types-D2-SSPM9.js';
2
2
 
3
3
  /**
4
4
  * Shared error type for every Throttle SDK.
@@ -153,6 +153,16 @@ declare const THROTTLE_CART_CODES: {
153
153
  readonly statuses: readonly [501];
154
154
  readonly retryable: false;
155
155
  };
156
+ /** The absorb (MC2-R39): a customer's cart never moves into a guest's or another customer's. */
157
+ readonly cart_owner_mismatch: {
158
+ readonly statuses: readonly [403];
159
+ readonly retryable: false;
160
+ };
161
+ /** The absorb: the two carts are priced by different apps. */
162
+ readonly cart_resolver_mismatch: {
163
+ readonly statuses: readonly [422];
164
+ readonly retryable: false;
165
+ };
156
166
  };
157
167
  type ThrottleCartCode = keyof typeof THROTTLE_CART_CODES;
158
168
 
@@ -6,7 +6,7 @@ import {
6
6
  STALE_MS,
7
7
  SUGGEST_DEBOUNCE_MS,
8
8
  SUGGEST_MIN_CHARS
9
- } from "./chunk-55G4ZTD5.js";
9
+ } from "./chunk-VJHA7J7B.js";
10
10
 
11
11
  // src/react/provider.tsx
12
12
  import {
@@ -183,6 +183,21 @@ function createStore(client, options = {}) {
183
183
  }
184
184
  }
185
185
  }
186
+ function invalidate() {
187
+ for (const [key, entry] of [...entries]) {
188
+ clearTimeout(entry.timer);
189
+ clearTimeout(entry.retry);
190
+ entry.flight?.abort();
191
+ if (entry.subs.size === 0) {
192
+ entries.delete(key);
193
+ } else {
194
+ entry.etag = entry.flight = entry.promise = void 0;
195
+ entry.indexing = false;
196
+ entry.retries = 0;
197
+ if (entry.run) void fetchKey(key, entry.run, { force: true });
198
+ }
199
+ }
200
+ }
186
201
  return {
187
202
  subscribe(key, listener) {
188
203
  const entry = touch(key);
@@ -202,6 +217,7 @@ function createStore(client, options = {}) {
202
217
  },
203
218
  fetch: fetchKey,
204
219
  clear,
220
+ invalidate,
205
221
  pause,
206
222
  pausedUntil: () => until > now() ? until : 0,
207
223
  dispose() {
@@ -218,6 +234,7 @@ function createStore(client, options = {}) {
218
234
  import { jsx } from "react/jsx-runtime";
219
235
  function createMemoryCartStorage() {
220
236
  let id = null;
237
+ const byCurrency = /* @__PURE__ */ new Map();
221
238
  return {
222
239
  get: () => id,
223
240
  set: (value) => {
@@ -225,6 +242,13 @@ function createMemoryCartStorage() {
225
242
  },
226
243
  remove: () => {
227
244
  id = null;
245
+ },
246
+ getFor: (currency) => byCurrency.get(currency) ?? null,
247
+ setFor: (currency, value) => {
248
+ byCurrency.set(currency, value);
249
+ },
250
+ removeFor: (currency) => {
251
+ byCurrency.delete(currency);
228
252
  }
229
253
  };
230
254
  }
@@ -352,7 +376,7 @@ function useQuery(route, params, run, options = {}) {
352
376
  }
353
377
 
354
378
  // src/react/hooks.ts
355
- import { useEffect as useEffect3, useMemo as useMemo2, useSyncExternalStore as useSyncExternalStore2 } from "react";
379
+ import { useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useSyncExternalStore as useSyncExternalStore2 } from "react";
356
380
  function defined(options) {
357
381
  const out = {};
358
382
  for (const name in options) {
@@ -458,6 +482,75 @@ function useConfig(options) {
458
482
  options
459
483
  );
460
484
  }
485
+ function useStorefrontSettings(options) {
486
+ return useQuery(
487
+ "settings",
488
+ null,
489
+ (client, call) => client.settings(call),
490
+ options
491
+ );
492
+ }
493
+ var watches = /* @__PURE__ */ new WeakMap();
494
+ var watchRun = (client, call) => client.config(call);
495
+ var pageDocument = () => Reflect.get(globalThis, "document");
496
+ function watch(store, intervalMs, listener) {
497
+ let w = watches.get(store);
498
+ if (w === void 0) {
499
+ const key = `\0${catalogQueryKey("generation", null)}`;
500
+ const visible = () => pageDocument().visibilityState === "visible";
501
+ const listeners = /* @__PURE__ */ new Set();
502
+ let known;
503
+ let hiddenAt = visible() ? 0 : Date.now();
504
+ const check = async () => {
505
+ if (!visible()) return;
506
+ await store.fetch(key, watchRun, { force: true });
507
+ const data = store.get(key).data;
508
+ if (watches.get(store) !== w || data?.generation === void 0) return;
509
+ const changed = known !== void 0 && data.generation !== known;
510
+ known = data.generation;
511
+ if (changed) {
512
+ store.invalidate();
513
+ for (const { current } of [...listeners]) current?.(known);
514
+ }
515
+ };
516
+ const onVisibility = () => {
517
+ if (!visible()) hiddenAt = Date.now();
518
+ else {
519
+ if (known === void 0 || Date.now() - hiddenAt >= intervalMs)
520
+ void check();
521
+ hiddenAt = 0;
522
+ }
523
+ };
524
+ void check();
525
+ const timer = setInterval(() => void check(), intervalMs);
526
+ const doc = pageDocument();
527
+ doc.addEventListener("visibilitychange", onVisibility);
528
+ w = {
529
+ listeners,
530
+ stop() {
531
+ clearInterval(timer);
532
+ doc.removeEventListener("visibilitychange", onVisibility);
533
+ watches.delete(store);
534
+ }
535
+ };
536
+ watches.set(store, w);
537
+ }
538
+ const mine = w;
539
+ mine.listeners.add(listener);
540
+ return () => {
541
+ mine.listeners.delete(listener);
542
+ if (mine.listeners.size === 0) mine.stop();
543
+ };
544
+ }
545
+ function useGenerationWatch({
546
+ intervalMs = 6e4,
547
+ onChange
548
+ } = {}) {
549
+ const { store } = useCatalogContext();
550
+ const latest = useRef3(onChange);
551
+ latest.current = onChange;
552
+ useEffect3(() => watch(store, intervalMs, latest), [store, intervalMs]);
553
+ }
461
554
  function useFilterValues(key, query = {}, options) {
462
555
  return useQuery(
463
556
  "filterValues",
@@ -484,5 +577,7 @@ export {
484
577
  useCollection,
485
578
  useReviews,
486
579
  useConfig,
580
+ useStorefrontSettings,
581
+ useGenerationWatch,
487
582
  useFilterValues
488
583
  };