@reblu/site-next 1.11.0 → 1.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # @reblu/site-next
2
2
 
3
+ ## 1.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - baa36cf: Add a plug-and-play `ShopFilters` component so a SITE never hand-builds shop
8
+ filter UI again (REB-809) — the gap that made the dermamedicin SITE hardcode
9
+ `BRAND_AXIS = 'brand'` while that tenant's real axis key is `marca`.
10
+
11
+ `@reblu/site-blocks` exports `ShopFilters` (root entry, RSC-safe): one
12
+ fieldset per non-empty `facets.taxonomies` axis (single-select, with a
13
+ click-to-clear toggle on the active term — see the component's doc comment
14
+ for why single-select, given the current `taxonomy[<key>]` query contract), a
15
+ multi-select fieldset per filterable attribute, a price-band fieldset
16
+ (`priceBands` prop, sensible EUR defaults, also click-to-clear), and an
17
+ on-sale toggle. A filter selection that collapses every facet to empty still
18
+ renders "Clear filters" rather than nothing — also exported as the standalone
19
+ predicate `shouldRenderShopFilters(facets, active)`, so a consuming page can
20
+ gate its own wrapping landmark on the identical decision. Every option always
21
+ renders a real `<a href>`; `onChange` is strictly additive for a Client
22
+ Component caller. Price-band labels ("Under €25", "€25 – €50", "€100+") come
23
+ from three overridable `ShopFiltersLabels` templates (`priceUnder`,
24
+ `priceOver`, `priceRange`), not hardcoded English, plus `currencyCode`/
25
+ `locale` props matching `ProductCard`'s. Tokens only, fieldset/legend
26
+ semantics, `aria-current` on the active option(s), zero accessibility
27
+ violations (`jest-axe`).
28
+
29
+ `@reblu/site-next` exports the URL glue from two new subpaths:
30
+ `./shop-server` (`parseShopSearchParams`, `serializeShopParams`,
31
+ `toURLSearchParams`, `buildShopFilterHrefs`, `getShopCollection` — all
32
+ server-safe, no `'use client'`) and `./shop` (adds the client-reactive
33
+ `useShopFilterLinks` hook). `parseShopSearchParams`/`serializeShopParams`
34
+ mirror base-api's own `parseListParams` bracket shape and MVP
35
+ single-value-per-taxonomy-axis collapse, so a URL this SDK builds is exactly
36
+ the URL the server parses back — including hardening against
37
+ prototype-pollution-shaped bracket keys (`taxonomy[__proto__]` and friends)
38
+ and strict digit-only numeric parsing, while `taxonomy`/`attr` on the
39
+ returned `ShopProductsParams` are always guaranteed PLAIN objects (never the
40
+ null-prototype accumulator used internally for that hardening) — React
41
+ Flight refuses a null-prototype prop crossing the Server→Client Component
42
+ boundary, and a Client Component caller passing `active={params}` into
43
+ `ShopFilters`' `onChange` mode is exactly the documented usage this
44
+ guarantees stays safe. `toURLSearchParams` converts Next's `searchParams`
45
+ route prop into a `URLSearchParams`, preserving a repeated key as a repeated
46
+ entry (the native `URLSearchParams` constructor does not). `getShopCollection`
47
+ fetches products and facets with the identical params, in parallel — a
48
+ products failure propagates, a facets failure degrades to an empty
49
+ `ShopFacets` so the listing survives rather than one upstream outage taking
50
+ both down, logs once (`console.warn('[getShopCollection] …')`) so the
51
+ degradation is never silent, and the resolved `ShopCollection` carries
52
+ `facetsStatus: 'ok' | 'unavailable'` so a caller can tell "no filterable axes
53
+ configured" apart from "facets endpoint is down" without parsing a log line.
54
+
55
+ ### Patch Changes
56
+
57
+ - Updated dependencies [baa36cf]
58
+ - @reblu/site-contracts@1.3.0
59
+
3
60
  ## 1.11.0
4
61
 
5
62
  ### Minor Changes
package/README.md CHANGED
@@ -23,6 +23,10 @@ intentionally Next-coupled so the lower SDK layers —
23
23
  the anonymous enrollment-request form, plus `deriveValidationFields` to
24
24
  build its per-tenant validation fields from course/config data. See
25
25
  "Academy" below.
26
+ 7. **Shop filters** (`./shop`, `./shop-server`) — URL ↔ `ShopProductsParams`
27
+ glue for `@reblu/site-blocks`'s `ShopFilters`: parse/serialize, a server
28
+ helper that fetches products + facets with identical params, and a
29
+ client-reactive navigation hook. See "Shop filters" below.
26
30
 
27
31
  ## Install
28
32
 
@@ -243,6 +247,71 @@ maps each `fieldErrors` key onto the matching input (`email`, or a
243
247
  `details`, or a `429` — renders as a single form-level error instead. No
244
248
  reCAPTCHA: the route is IP rate-limited server-side.
245
249
 
250
+ ## Shop filters
251
+
252
+ `./shop-server` and `./shop` (REB-809) are the URL glue behind
253
+ `@reblu/site-blocks`'s `ShopFilters` — see that package's README for the full
254
+ example. Two subpaths, same split reasoning as `./academy`/`./academy-server`:
255
+
256
+ | Export | Subpath | Needs `'use client'`? |
257
+ | ------ | ------- | ---------------------- |
258
+ | `parseShopSearchParams(searchParams): ShopProductsParams` | both | No |
259
+ | `serializeShopParams(params): URLSearchParams` | both | No |
260
+ | `toURLSearchParams(nextSearchParams): URLSearchParams` | both | No |
261
+ | `buildShopFilterHrefs(current, pathname): ShopFilterHrefs` | both | No |
262
+ | `getShopCollection(client, params?)` | both | No |
263
+ | `useShopFilterLinks(): ShopFilterHrefs` | `./shop` only | **Yes** |
264
+
265
+ `toURLSearchParams(searchParams)` converts Next's route `searchParams` prop —
266
+ `Record<string, string | string[] | undefined>`, a repeated query key already
267
+ grouped into an array — into the `URLSearchParams` `parseShopSearchParams`
268
+ expects, preserving every repeated key as a repeated entry. Use it, not
269
+ `new URLSearchParams(await searchParams)`: the native constructor only
270
+ accepts `Record<string, string>`, so handed an array value it calls
271
+ `String()` on it and produces ONE entry (`attr[color]=red,blue`) instead of
272
+ two repeated ones — silently breaking `parseShopSearchParams`'s
273
+ repeat-count-based array/scalar distinction for every multi-value
274
+ `attr[<key>]` filter. See `@reblu/site-blocks`'s README for the full
275
+ Server Component recipe built on this.
276
+
277
+ `parseShopSearchParams`/`serializeShopParams` are a deliberate MIRROR of
278
+ base-api's own `parseListParams`
279
+ (`apps/base-api/src/lib/shop/catalogue-query.ts`) — same bracket shape
280
+ (`taxonomy[<key>]=<slug>`, `attr[<key>]=<value>`, repeatable for `attr`), same
281
+ MVP collapse of a repeated `taxonomy[<key>]` to its first value. A URL this
282
+ module builds is exactly the URL the server parses back to the same params —
283
+ there is no second, drifting parser. `serializeShopParams` round-trips
284
+ through `parseShopSearchParams` for any canonical input; a single-element
285
+ `attr` array is the one documented exception — see `params.test.ts` — because
286
+ one `attr[key]=value` occurrence is indistinguishable from a plain scalar
287
+ once serialized (the array/scalar distinction is carried entirely by repeat
288
+ count). Reading it back with `Array.isArray(...) ? value : [value]` (exactly
289
+ what `ShopFilters` does) makes the distinction invisible to a consumer.
290
+
291
+ `getShopCollection(client, params)` fetches `client.shop.products(params)`
292
+ and `client.shop.facets(params)` **with the identical params object**, in
293
+ parallel — the seam that keeps a collection page correct. A hand-rolled page
294
+ that calls the two endpoints with separately-derived params is exactly how a
295
+ facet count and its product list drift apart.
296
+
297
+ `products` and `facets` are not equally critical: a products failure
298
+ propagates (`getShopCollection` rejects — there is no listing without
299
+ products), while a facets failure degrades to an empty `ShopFacets` so the
300
+ listing still renders, just without filter UI. That degradation is never
301
+ silent: it logs `console.warn('[getShopCollection] facets fetch failed, …')`
302
+ once, and the resolved `ShopCollection` carries `facetsStatus: 'ok' |
303
+ 'unavailable'` so a caller that wants to act on it (retry, surface a banner)
304
+ can tell "this tenant configured no filterable axes" apart from "the facets
305
+ endpoint is down right now" without parsing a log line.
306
+
307
+ `buildShopFilterHrefs(current, pathname)` is pure (no `next/navigation`) — a
308
+ Server Component calls it directly with its own `searchParams` prop and
309
+ route pathname. Every returned href preserves every OTHER current param and
310
+ resets pagination (`page`) — a filter change is never silently stuck on page
311
+ 4 of a now-smaller result set. `useShopFilterLinks()` is the same logic bound
312
+ reactively to `usePathname()`/`useSearchParams()`, for a filter bar that is
313
+ its own Client Component.
314
+
246
315
  ## Structured data (JSON-LD)
247
316
 
248
317
  `@reblu/site-next/json-ld` (REB-622, server-only, dependency-free) renders one
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/shop/server.ts
21
+ var server_exports = {};
22
+ __export(server_exports, {
23
+ buildShopFilterHrefs: () => buildShopFilterHrefs,
24
+ getShopCollection: () => getShopCollection,
25
+ parseShopSearchParams: () => parseShopSearchParams,
26
+ serializeShopParams: () => serializeShopParams,
27
+ toURLSearchParams: () => toURLSearchParams
28
+ });
29
+ module.exports = __toCommonJS(server_exports);
30
+
31
+ // src/shop/params.ts
32
+ var MAX_Q_LEN = 200;
33
+ var MAX_FILTER_KEYS = 30;
34
+ var MAX_VALUE_LEN = 120;
35
+ var MAX_VALUES_PER_KEY = 30;
36
+ var MAX_LIMIT = 50;
37
+ var SHOP_SORTS = ["price_asc", "price_desc", "newest", "on_sale"];
38
+ function isShopSort(value) {
39
+ return SHOP_SORTS.includes(value);
40
+ }
41
+ function nonNegativeInt(raw) {
42
+ if (raw === null || !/^\d+$/.test(raw)) return void 0;
43
+ const n = Number.parseInt(raw, 10);
44
+ return Number.isNaN(n) ? void 0 : n;
45
+ }
46
+ function toPlainObject(source) {
47
+ return { ...source };
48
+ }
49
+ function parseBracketParams(searchParams, prefix) {
50
+ const out = /* @__PURE__ */ Object.create(null);
51
+ const re = new RegExp(`^${prefix}\\[(.+)\\]$`);
52
+ for (const [rawKey, rawValue] of searchParams.entries()) {
53
+ const match = rawKey.match(re);
54
+ if (!match) continue;
55
+ const key = match[1];
56
+ const value = rawValue.slice(0, MAX_VALUE_LEN);
57
+ if (!(key in out) && Object.keys(out).length >= MAX_FILTER_KEYS) continue;
58
+ const existing = out[key];
59
+ if (existing === void 0) out[key] = value;
60
+ else if (Array.isArray(existing)) {
61
+ if (existing.length < MAX_VALUES_PER_KEY) existing.push(value);
62
+ } else out[key] = [existing, value];
63
+ }
64
+ return Object.keys(out).length > 0 ? toPlainObject(out) : void 0;
65
+ }
66
+ function parseShopSearchParams(searchParams) {
67
+ const params = {};
68
+ const page = nonNegativeInt(searchParams.get("page"));
69
+ if (page !== void 0 && page >= 1) params.page = page;
70
+ const limit = nonNegativeInt(searchParams.get("limit"));
71
+ if (limit !== void 0 && limit >= 1 && limit <= MAX_LIMIT) params.limit = limit;
72
+ const q = searchParams.get("q")?.trim().slice(0, MAX_Q_LEN);
73
+ if (q) params.q = q;
74
+ const sort = searchParams.get("sort");
75
+ if (sort !== null && isShopSort(sort)) params.sort = sort;
76
+ const category = searchParams.get("category")?.trim();
77
+ if (category) params.category = category;
78
+ let priceMin = nonNegativeInt(searchParams.get("priceMin"));
79
+ let priceMax = nonNegativeInt(searchParams.get("priceMax"));
80
+ if (priceMin !== void 0 && priceMax !== void 0 && priceMin > priceMax) {
81
+ ;
82
+ [priceMin, priceMax] = [priceMax, priceMin];
83
+ }
84
+ if (priceMin !== void 0) params.priceMin = priceMin;
85
+ if (priceMax !== void 0) params.priceMax = priceMax;
86
+ const onSale = searchParams.get("onSale");
87
+ if (onSale === "true" || onSale === "1") params.onSale = true;
88
+ const taxonomyRaw = parseBracketParams(searchParams, "taxonomy");
89
+ if (taxonomyRaw) {
90
+ const taxonomy = /* @__PURE__ */ Object.create(null);
91
+ for (const [key, value] of Object.entries(taxonomyRaw)) {
92
+ taxonomy[key] = Array.isArray(value) ? value[0] : value;
93
+ }
94
+ params.taxonomy = toPlainObject(taxonomy);
95
+ }
96
+ const attr = parseBracketParams(searchParams, "attr");
97
+ if (attr) params.attr = attr;
98
+ return params;
99
+ }
100
+ function serializeShopParams(params) {
101
+ const sp = new URLSearchParams();
102
+ if (params.page !== void 0) sp.set("page", String(params.page));
103
+ if (params.limit !== void 0) sp.set("limit", String(params.limit));
104
+ if (params.q) sp.set("q", params.q);
105
+ if (params.sort) sp.set("sort", params.sort);
106
+ if (params.category) sp.set("category", params.category);
107
+ if (params.priceMin !== void 0) sp.set("priceMin", String(params.priceMin));
108
+ if (params.priceMax !== void 0) sp.set("priceMax", String(params.priceMax));
109
+ if (params.onSale) sp.set("onSale", "true");
110
+ if (params.taxonomy) {
111
+ for (const [key, value] of Object.entries(params.taxonomy)) {
112
+ if (value) sp.set(`taxonomy[${key}]`, value);
113
+ }
114
+ }
115
+ if (params.attr) {
116
+ for (const [key, value] of Object.entries(params.attr)) {
117
+ if (Array.isArray(value)) {
118
+ for (const v of value) if (v) sp.append(`attr[${key}]`, v);
119
+ } else if (value) {
120
+ sp.set(`attr[${key}]`, value);
121
+ }
122
+ }
123
+ }
124
+ return sp;
125
+ }
126
+ function toURLSearchParams(searchParams) {
127
+ const usp = new URLSearchParams();
128
+ for (const [key, value] of Object.entries(searchParams)) {
129
+ if (value === void 0) continue;
130
+ if (Array.isArray(value)) {
131
+ for (const v of value) usp.append(key, v);
132
+ } else {
133
+ usp.append(key, value);
134
+ }
135
+ }
136
+ return usp;
137
+ }
138
+ function buildShopFilterHrefs(current, pathname) {
139
+ function hrefFor(patch) {
140
+ const next = { ...current, ...patch, page: void 0 };
141
+ const qs = serializeShopParams(next).toString();
142
+ return qs ? `${pathname}?${qs}` : pathname;
143
+ }
144
+ return {
145
+ taxonomy: (axisKey, value) => {
146
+ const taxonomy = { ...current.taxonomy };
147
+ if (value === void 0) delete taxonomy[axisKey];
148
+ else taxonomy[axisKey] = value;
149
+ return hrefFor({ taxonomy });
150
+ },
151
+ attr: (fieldKey, value) => {
152
+ const existing = current.attr?.[fieldKey];
153
+ const values = existing === void 0 ? [] : Array.isArray(existing) ? existing : [existing];
154
+ const next = values.includes(value) ? values.filter((v) => v !== value) : [...values, value];
155
+ const attr = { ...current.attr };
156
+ if (next.length === 0) delete attr[fieldKey];
157
+ else attr[fieldKey] = next;
158
+ return hrefFor({ attr });
159
+ },
160
+ price: (min, max) => hrefFor({ priceMin: min, priceMax: max }),
161
+ onSale: (nextActive) => hrefFor({ onSale: nextActive || void 0 }),
162
+ // Clears every FACET dimension only — q/category/sort (the non-facet part
163
+ // of the query) are deliberately preserved, mirroring ShopFilters' own
164
+ // onChange clear-all patch.
165
+ clearAll: () => hrefFor({ taxonomy: void 0, attr: void 0, priceMin: void 0, priceMax: void 0, onSale: void 0 })
166
+ };
167
+ }
168
+
169
+ // src/shop/collection.ts
170
+ var EMPTY_FACETS = {
171
+ taxonomies: [],
172
+ attributes: [],
173
+ price: { min: 0, max: 0 },
174
+ onSale: { count: 0 }
175
+ };
176
+ async function getShopCollection(client, params) {
177
+ const [productsResult, facetsResult] = await Promise.allSettled([
178
+ client.shop.products(params),
179
+ client.shop.facets(params)
180
+ ]);
181
+ if (productsResult.status === "rejected") {
182
+ throw productsResult.reason;
183
+ }
184
+ if (facetsResult.status === "rejected") {
185
+ console.warn("[getShopCollection] facets fetch failed, degrading to empty ShopFacets:", facetsResult.reason);
186
+ return { products: productsResult.value, facets: EMPTY_FACETS, facetsStatus: "unavailable" };
187
+ }
188
+ return { products: productsResult.value, facets: facetsResult.value, facetsStatus: "ok" };
189
+ }
190
+ // Annotate the CommonJS export names for ESM import in node:
191
+ 0 && (module.exports = {
192
+ buildShopFilterHrefs,
193
+ getShopCollection,
194
+ parseShopSearchParams,
195
+ serializeShopParams,
196
+ toURLSearchParams
197
+ });
198
+ //# sourceMappingURL=shop-server.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/shop/server.ts","../src/shop/params.ts","../src/shop/collection.ts"],"sourcesContent":["/**\n * `@reblu/site-next/shop-server` (REB-809) — the server-safe half of the shop\n * filters glue. Every export here has ZERO React and must NEVER carry\n * `'use client'` — same isolation direction as `./route-handlers` and\n * `./academy-server` (see `tsup.config.ts`). A Server Component (a route's\n * `page.tsx`) calls these directly: parse its own `searchParams` prop,\n * fetch the collection, build hrefs, and render\n * `@reblu/site-blocks`'s `ShopFilters` with NO `onChange` and NO client\n * boundary at all — the exact \"importable from a Server Component\" shape\n * REB-647/REB-809 require. `./shop` re-exports the same pure helpers\n * alongside the `'use client'` navigation hook for a page that wants the\n * client-reactive version instead.\n */\nexport {\n parseShopSearchParams,\n serializeShopParams,\n toURLSearchParams,\n buildShopFilterHrefs,\n type ShopFilterHrefs,\n} from './params'\nexport { getShopCollection, type ShopCollection } from './collection'\n","import type { ShopProductsParams } from '@reblu/site-client'\n\n/**\n * REB-809 — URL ↔ `ShopProductsParams` glue for a SITE's shop/collection page.\n *\n * Mirrors base-api's own `parseListParams`\n * (`apps/base-api/src/lib/shop/catalogue-query.ts`) deliberately: the SAME\n * bracket shape (`taxonomy[<key>]=<slug>`, `attr[<key>]=<value>`, repeatable\n * for `attr`), the SAME MVP collapse for `taxonomy` (one term slug per axis —\n * a repeat keeps only the first), and the SAME defensive caps on bracket\n * key/value counts. Keeping this a mirror rather than a re-implementation\n * means a URL this module builds is EXACTLY the URL the server will parse\n * back to the same params — there is no second, drifting parser.\n */\n\nconst MAX_Q_LEN = 200\nconst MAX_FILTER_KEYS = 30\nconst MAX_VALUE_LEN = 120\nconst MAX_VALUES_PER_KEY = 30\n/**\n * Mirrors `shopProductListParamsSchema`'s `limit: z.number().int().min(1).max(50)`\n * in `apps/base-api/src/lib/contracts/shop.contract.ts` — keep the two in sync;\n * a caller-side limit above this is server-rejected/clamped anyway, but parsing\n * it through here means a URL this module builds never claims a limit the\n * server will refuse.\n */\nconst MAX_LIMIT = 50\n\nconst SHOP_SORTS = ['price_asc', 'price_desc', 'newest', 'on_sale'] as const\n\nfunction isShopSort(value: string): value is (typeof SHOP_SORTS)[number] {\n return (SHOP_SORTS as readonly string[]).includes(value)\n}\n\n/**\n * Parse a non-negative integer, STRICT digits only — `/^\\d+$/`, so '12abc'\n * and '1.5' are rejected outright rather than `Number.parseInt` silently\n * taking the leading '12'/'1' and discarding the rest as if it were valid\n * input. Named for exactly what it accepts: >= 0, not \"positive\" (which\n * excludes 0) — callers that need >= 1 (page, limit) enforce that bound\n * themselves on top of this.\n */\nfunction nonNegativeInt(raw: string | null): number | undefined {\n if (raw === null || !/^\\d+$/.test(raw)) return undefined\n const n = Number.parseInt(raw, 10)\n return Number.isNaN(n) ? undefined : n\n}\n\n/**\n * Copy a (possibly null-prototype) object's own enumerable properties into a\n * fresh PLAIN object — `Object.prototype` in its chain, nothing else.\n *\n * R3-001: this module's internal accumulators use `Object.create(null)` for\n * prototype-pollution safety (see `parseBracketParams`' own doc below), but\n * that safety property must NEVER leak into `ShopProductsParams.taxonomy`/\n * `.attr`, the values a SITE actually receives. React Flight — the\n * serialization React Server Components use to send props across the\n * Server→Client boundary — refuses a non-plain object (`Object.\n * getPrototypeOf(x) !== Object.prototype`) with a hard runtime error. This\n * package's own README tells a Client Component caller to pass\n * `active={params}` to `ShopFilters`' `onChange` mode, so a null-prototype\n * `taxonomy`/`attr` reaching that boundary would crash every SITE that\n * followed the documented pattern. Object spread (`{ ...source }`) always\n * produces a plain object regardless of the source's own prototype, which is\n * exactly the property this needs and `Object.assign({}, source)` also has —\n * spread is used here for clarity.\n */\nfunction toPlainObject<T>(source: Record<string, T>): Record<string, T> {\n return { ...source }\n}\n\n/**\n * Parse `bracket[key]` params into a map, collapsing repeats into arrays.\n *\n * Built on `Object.create(null)` rather than a plain `{}` — a query string is\n * attacker-controlled input, and `taxonomy[constructor]=x` or\n * `taxonomy[toString]=x` against a plain object reads/writes through the\n * INHERITED `Object.prototype` property of that name before this function's\n * own `key in out` / `out[key]` logic ever sees an actual own property,\n * corrupting the key-count bookkeeping and the parsed value for that key. A\n * null-prototype object has no inherited properties at all, so `in` and\n * indexed access only ever see what this loop itself has written — the same\n * defense `JSON.parse` reviewers reach for against prototype-pollution-style\n * bracket keys, applied here to a hand-rolled key/value accumulator.\n *\n * The RETURNED object is converted back to a plain one via\n * {@link toPlainObject} — the null prototype is this function's OWN internal\n * bookkeeping safety, never part of its public output (see that helper's doc\n * for why: React Flight/RSC serialization).\n */\nfunction parseBracketParams(\n searchParams: URLSearchParams,\n prefix: string,\n): Record<string, string | string[]> | undefined {\n const out: Record<string, string | string[]> = Object.create(null)\n const re = new RegExp(`^${prefix}\\\\[(.+)\\\\]$`)\n for (const [rawKey, rawValue] of searchParams.entries()) {\n const match = rawKey.match(re)\n if (!match) continue\n const key = match[1]\n const value = rawValue.slice(0, MAX_VALUE_LEN)\n if (!(key in out) && Object.keys(out).length >= MAX_FILTER_KEYS) continue\n const existing = out[key]\n if (existing === undefined) out[key] = value\n else if (Array.isArray(existing)) {\n if (existing.length < MAX_VALUES_PER_KEY) existing.push(value)\n } else out[key] = [existing, value]\n }\n return Object.keys(out).length > 0 ? toPlainObject(out) : undefined\n}\n\n/**\n * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`\n * from `next/navigation`, or a plain `new URLSearchParams(record)`) into\n * `ShopProductsParams`. Hostile/unknown params are dropped: only the known\n * scalar keys and the two bracket groups are ever read.\n */\nexport function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams {\n const params: ShopProductsParams = {}\n\n const page = nonNegativeInt(searchParams.get('page'))\n if (page !== undefined && page >= 1) params.page = page\n\n const limit = nonNegativeInt(searchParams.get('limit'))\n if (limit !== undefined && limit >= 1 && limit <= MAX_LIMIT) params.limit = limit\n\n const q = searchParams.get('q')?.trim().slice(0, MAX_Q_LEN)\n if (q) params.q = q\n\n const sort = searchParams.get('sort')\n if (sort !== null && isShopSort(sort)) params.sort = sort\n\n const category = searchParams.get('category')?.trim()\n if (category) params.category = category\n\n let priceMin = nonNegativeInt(searchParams.get('priceMin'))\n let priceMax = nonNegativeInt(searchParams.get('priceMax'))\n // An inverted range (priceMin > priceMax) would otherwise always match zero\n // products server-side — swap rather than pass through a query that can\n // never return a result, or silently drop half of it.\n if (priceMin !== undefined && priceMax !== undefined && priceMin > priceMax) {\n ;[priceMin, priceMax] = [priceMax, priceMin]\n }\n // Only assign when actually present — an explicit `priceMin: undefined` key\n // is never written, so `toStrictEqual`/`toEqual` against a literal object\n // without that key both hold, and a downstream `'priceMin' in params` check\n // reflects reality.\n if (priceMin !== undefined) params.priceMin = priceMin\n if (priceMax !== undefined) params.priceMax = priceMax\n\n const onSale = searchParams.get('onSale')\n if (onSale === 'true' || onSale === '1') params.onSale = true\n\n const taxonomyRaw = parseBracketParams(searchParams, 'taxonomy')\n if (taxonomyRaw) {\n // A taxonomy axis takes a single term slug in MVP — collapse any repeats,\n // exactly like the server does (see module doc). Object.create(null) for\n // the SAME reason as parseBracketParams' own accumulator: `key` is\n // attacker-controlled (it is the bracket-key from the query string), and\n // a plain `{}` here reintroduces the exact bug parseBracketParams just\n // fixed one step downstream — `taxonomy['__proto__'] = value` on a plain\n // object hits the inherited `__proto__` ACCESSOR, which silently no-ops\n // for a non-object value instead of creating an own property, so that\n // key would vanish from the result entirely (not merely mis-set).\n const taxonomy: Record<string, string> = Object.create(null)\n for (const [key, value] of Object.entries(taxonomyRaw)) {\n taxonomy[key] = Array.isArray(value) ? value[0] : value\n }\n // Plain object out, same reason as parseBracketParams' own return value —\n // see toPlainObject's doc (R3-001, React Flight/RSC serialization).\n params.taxonomy = toPlainObject(taxonomy)\n }\n\n const attr = parseBracketParams(searchParams, 'attr')\n if (attr) params.attr = attr\n\n return params\n}\n\n/**\n * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of\n * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so\n * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`\n * for any already-canonical (non-empty-string, in-range) input — that is the\n * property the round-trip tests pin.\n */\nexport function serializeShopParams(params: ShopProductsParams): URLSearchParams {\n const sp = new URLSearchParams()\n\n if (params.page !== undefined) sp.set('page', String(params.page))\n if (params.limit !== undefined) sp.set('limit', String(params.limit))\n if (params.q) sp.set('q', params.q)\n if (params.sort) sp.set('sort', params.sort)\n if (params.category) sp.set('category', params.category)\n if (params.priceMin !== undefined) sp.set('priceMin', String(params.priceMin))\n if (params.priceMax !== undefined) sp.set('priceMax', String(params.priceMax))\n if (params.onSale) sp.set('onSale', 'true')\n\n if (params.taxonomy) {\n for (const [key, value] of Object.entries(params.taxonomy)) {\n if (value) sp.set(`taxonomy[${key}]`, value)\n }\n }\n\n if (params.attr) {\n for (const [key, value] of Object.entries(params.attr)) {\n if (Array.isArray(value)) {\n for (const v of value) if (v) sp.append(`attr[${key}]`, v)\n } else if (value) {\n sp.set(`attr[${key}]`, value)\n }\n }\n }\n\n return sp\n}\n\n/**\n * Convert Next's `searchParams` route prop —\n * `Record<string, string | string[] | undefined>`, already URL-decoded, with\n * a repeated query key grouped into an array — into a `URLSearchParams` for\n * {@link parseShopSearchParams}.\n *\n * NOT the same as `new URLSearchParams(searchParams)`: the native\n * `URLSearchParams` constructor only accepts `Record<string, string>` —\n * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls\n * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two\n * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently\n * breaks `parseShopSearchParams`'s repeat-count-based array/scalar\n * distinction (see its own module doc) for every multi-value `attr[<key>]`\n * filter — this helper is what actually preserves the repeat.\n */\nexport function toURLSearchParams(\n searchParams: Record<string, string | string[] | undefined>,\n): URLSearchParams {\n const usp = new URLSearchParams()\n for (const [key, value] of Object.entries(searchParams)) {\n if (value === undefined) continue\n if (Array.isArray(value)) {\n for (const v of value) usp.append(key, v)\n } else {\n usp.append(key, value)\n }\n }\n return usp\n}\n\n/**\n * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept\n * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package\n * dependency on `@reblu/site-blocks` (this package has none today, and this\n * one shape does not warrant adding one). Any object matching this shape is\n * assignable to `ShopFiltersHrefs` by structural typing.\n */\nexport interface ShopFilterHrefs {\n taxonomy: (axisKey: string, value: string | undefined) => string\n attr: (fieldKey: string, value: string) => string\n price: (min: number | undefined, max: number | undefined) => string\n onSale: (nextActive: boolean) => string\n clearAll: () => string\n}\n\n/**\n * Build `ShopFilters`' href set from the CURRENT params and pathname. Every\n * href preserves every OTHER current param (category, q, sort, every other\n * taxonomy/attr filter) and resets pagination (`page`) — a filter change is\n * never silently stuck on page 4 of a now-smaller result set.\n *\n * Pure and framework-agnostic (no `next/navigation`): a Server Component calls\n * it directly with its own `searchParams` prop and route pathname — no\n * `'use client'` boundary required. See `./use-shop-filter-links` for the\n * client-side hook that wraps this with `usePathname`/`useSearchParams`.\n */\nexport function buildShopFilterHrefs(\n current: ShopProductsParams,\n pathname: string,\n): ShopFilterHrefs {\n function hrefFor(patch: Partial<ShopProductsParams>): string {\n // Any filter change resets pagination — the previous page number almost\n // never still makes sense against the new (usually smaller) result set.\n const next: ShopProductsParams = { ...current, ...patch, page: undefined }\n const qs = serializeShopParams(next).toString()\n return qs ? `${pathname}?${qs}` : pathname\n }\n\n return {\n taxonomy: (axisKey, value) => {\n const taxonomy = { ...current.taxonomy }\n if (value === undefined) delete taxonomy[axisKey]\n else taxonomy[axisKey] = value\n return hrefFor({ taxonomy })\n },\n attr: (fieldKey, value) => {\n const existing = current.attr?.[fieldKey]\n const values = existing === undefined ? [] : Array.isArray(existing) ? existing : [existing]\n const next = values.includes(value) ? values.filter((v) => v !== value) : [...values, value]\n const attr = { ...current.attr }\n if (next.length === 0) delete attr[fieldKey]\n else attr[fieldKey] = next\n return hrefFor({ attr })\n },\n price: (min, max) => hrefFor({ priceMin: min, priceMax: max }),\n onSale: (nextActive) => hrefFor({ onSale: nextActive || undefined }),\n // Clears every FACET dimension only — q/category/sort (the non-facet part\n // of the query) are deliberately preserved, mirroring ShopFilters' own\n // onChange clear-all patch.\n clearAll: () =>\n hrefFor({ taxonomy: undefined, attr: undefined, priceMin: undefined, priceMax: undefined, onSale: undefined }),\n }\n}\n","import type { RebluClient, ListResult } from '@reblu/site-client'\nimport type { ShopFacets, ShopProductListItem } from '@reblu/site-contracts'\nimport type { ShopProductsParams } from '@reblu/site-client'\n\n/**\n * `getShopCollection` (REB-809) — fetches the product list AND its facets with\n * the EXACT SAME params in one call. This is the seam that keeps a shop\n * collection page correct: facet counts must reflect the same filter context\n * the product list is showing, and a hand-rolled page that calls\n * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`\n * separately is exactly how they drift apart.\n *\n * Server-safe (no `'use client'`, no React) — call it directly from an async\n * Server Component (route `page.tsx`) with the params parsed via\n * {@link parseShopSearchParams} from that page's `searchParams` prop.\n */\nexport interface ShopCollection {\n products: ListResult<ShopProductListItem>\n facets: ShopFacets\n /**\n * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;\n * `'unavailable'` when it failed and `facets` above is the degraded\n * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an\n * empty `facets` as \"this tenant configured no filterable axes\" when it may\n * really mean \"the facets endpoint is down right now\". A logged\n * `console.warn` (see below) covers the case nobody checks this field.\n */\n facetsStatus: 'ok' | 'unavailable'\n}\n\n/**\n * Returned when `client.shop.facets(...)` fails — a `ShopFilters` that\n * receives this simply renders nothing (`shouldRenderShopFilters` is false\n * for it with no active selection), which is the correct degraded state: no\n * filter UI, rather than no page at all. Paired with `facetsStatus:\n * 'unavailable'` on the returned {@link ShopCollection}, so a caller that\n * DOES care can tell \"no axes configured\" apart from \"facets endpoint down\".\n */\nconst EMPTY_FACETS: ShopFacets = {\n taxonomies: [],\n attributes: [],\n price: { min: 0, max: 0 },\n onSale: { count: 0 },\n}\n\n/**\n * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT\n * equally critical. A product listing with no products is not a listing —\n * that failure must propagate so the caller's own error handling (or a\n * thrown-away `try/catch` degrading the whole page) sees it. A product\n * listing with no FACETS is still a perfectly good listing, just without\n * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead\n * of letting one upstream endpoint's outage take the other down too, is what\n * `Promise.allSettled` (rather than `Promise.all`) buys here.\n *\n * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged\n * `[getShopCollection]` — the same convention `@reblu/site-next` already uses\n * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,\n * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the\n * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on\n * it (retry, surface a banner, alert) do so without parsing a log line.\n */\nexport async function getShopCollection(\n client: RebluClient,\n params?: ShopProductsParams,\n): Promise<ShopCollection> {\n const [productsResult, facetsResult] = await Promise.allSettled([\n client.shop.products(params),\n client.shop.facets(params),\n ])\n\n if (productsResult.status === 'rejected') {\n throw productsResult.reason\n }\n\n if (facetsResult.status === 'rejected') {\n console.warn('[getShopCollection] facets fetch failed, degrading to empty ShopFacets:', facetsResult.reason)\n return { products: productsResult.value, facets: EMPTY_FACETS, facetsStatus: 'unavailable' }\n }\n\n return { products: productsResult.value, facets: facetsResult.value, facetsStatus: 'ok' }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,106 @@
1
+ import { ShopProductsParams, ListResult, RebluClient } from '@reblu/site-client';
2
+ import { ShopProductListItem, ShopFacets } from '@reblu/site-contracts';
3
+
4
+ /**
5
+ * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`
6
+ * from `next/navigation`, or a plain `new URLSearchParams(record)`) into
7
+ * `ShopProductsParams`. Hostile/unknown params are dropped: only the known
8
+ * scalar keys and the two bracket groups are ever read.
9
+ */
10
+ declare function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams;
11
+ /**
12
+ * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of
13
+ * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so
14
+ * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`
15
+ * for any already-canonical (non-empty-string, in-range) input — that is the
16
+ * property the round-trip tests pin.
17
+ */
18
+ declare function serializeShopParams(params: ShopProductsParams): URLSearchParams;
19
+ /**
20
+ * Convert Next's `searchParams` route prop —
21
+ * `Record<string, string | string[] | undefined>`, already URL-decoded, with
22
+ * a repeated query key grouped into an array — into a `URLSearchParams` for
23
+ * {@link parseShopSearchParams}.
24
+ *
25
+ * NOT the same as `new URLSearchParams(searchParams)`: the native
26
+ * `URLSearchParams` constructor only accepts `Record<string, string>` —
27
+ * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls
28
+ * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two
29
+ * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently
30
+ * breaks `parseShopSearchParams`'s repeat-count-based array/scalar
31
+ * distinction (see its own module doc) for every multi-value `attr[<key>]`
32
+ * filter — this helper is what actually preserves the repeat.
33
+ */
34
+ declare function toURLSearchParams(searchParams: Record<string, string | string[] | undefined>): URLSearchParams;
35
+ /**
36
+ * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept
37
+ * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package
38
+ * dependency on `@reblu/site-blocks` (this package has none today, and this
39
+ * one shape does not warrant adding one). Any object matching this shape is
40
+ * assignable to `ShopFiltersHrefs` by structural typing.
41
+ */
42
+ interface ShopFilterHrefs {
43
+ taxonomy: (axisKey: string, value: string | undefined) => string;
44
+ attr: (fieldKey: string, value: string) => string;
45
+ price: (min: number | undefined, max: number | undefined) => string;
46
+ onSale: (nextActive: boolean) => string;
47
+ clearAll: () => string;
48
+ }
49
+ /**
50
+ * Build `ShopFilters`' href set from the CURRENT params and pathname. Every
51
+ * href preserves every OTHER current param (category, q, sort, every other
52
+ * taxonomy/attr filter) and resets pagination (`page`) — a filter change is
53
+ * never silently stuck on page 4 of a now-smaller result set.
54
+ *
55
+ * Pure and framework-agnostic (no `next/navigation`): a Server Component calls
56
+ * it directly with its own `searchParams` prop and route pathname — no
57
+ * `'use client'` boundary required. See `./use-shop-filter-links` for the
58
+ * client-side hook that wraps this with `usePathname`/`useSearchParams`.
59
+ */
60
+ declare function buildShopFilterHrefs(current: ShopProductsParams, pathname: string): ShopFilterHrefs;
61
+
62
+ /**
63
+ * `getShopCollection` (REB-809) — fetches the product list AND its facets with
64
+ * the EXACT SAME params in one call. This is the seam that keeps a shop
65
+ * collection page correct: facet counts must reflect the same filter context
66
+ * the product list is showing, and a hand-rolled page that calls
67
+ * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`
68
+ * separately is exactly how they drift apart.
69
+ *
70
+ * Server-safe (no `'use client'`, no React) — call it directly from an async
71
+ * Server Component (route `page.tsx`) with the params parsed via
72
+ * {@link parseShopSearchParams} from that page's `searchParams` prop.
73
+ */
74
+ interface ShopCollection {
75
+ products: ListResult<ShopProductListItem>;
76
+ facets: ShopFacets;
77
+ /**
78
+ * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;
79
+ * `'unavailable'` when it failed and `facets` above is the degraded
80
+ * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an
81
+ * empty `facets` as "this tenant configured no filterable axes" when it may
82
+ * really mean "the facets endpoint is down right now". A logged
83
+ * `console.warn` (see below) covers the case nobody checks this field.
84
+ */
85
+ facetsStatus: 'ok' | 'unavailable';
86
+ }
87
+ /**
88
+ * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT
89
+ * equally critical. A product listing with no products is not a listing —
90
+ * that failure must propagate so the caller's own error handling (or a
91
+ * thrown-away `try/catch` degrading the whole page) sees it. A product
92
+ * listing with no FACETS is still a perfectly good listing, just without
93
+ * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead
94
+ * of letting one upstream endpoint's outage take the other down too, is what
95
+ * `Promise.allSettled` (rather than `Promise.all`) buys here.
96
+ *
97
+ * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged
98
+ * `[getShopCollection]` — the same convention `@reblu/site-next` already uses
99
+ * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,
100
+ * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the
101
+ * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on
102
+ * it (retry, surface a banner, alert) do so without parsing a log line.
103
+ */
104
+ declare function getShopCollection(client: RebluClient, params?: ShopProductsParams): Promise<ShopCollection>;
105
+
106
+ export { type ShopCollection, type ShopFilterHrefs, buildShopFilterHrefs, getShopCollection, parseShopSearchParams, serializeShopParams, toURLSearchParams };
@@ -0,0 +1,106 @@
1
+ import { ShopProductsParams, ListResult, RebluClient } from '@reblu/site-client';
2
+ import { ShopProductListItem, ShopFacets } from '@reblu/site-contracts';
3
+
4
+ /**
5
+ * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`
6
+ * from `next/navigation`, or a plain `new URLSearchParams(record)`) into
7
+ * `ShopProductsParams`. Hostile/unknown params are dropped: only the known
8
+ * scalar keys and the two bracket groups are ever read.
9
+ */
10
+ declare function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams;
11
+ /**
12
+ * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of
13
+ * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so
14
+ * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`
15
+ * for any already-canonical (non-empty-string, in-range) input — that is the
16
+ * property the round-trip tests pin.
17
+ */
18
+ declare function serializeShopParams(params: ShopProductsParams): URLSearchParams;
19
+ /**
20
+ * Convert Next's `searchParams` route prop —
21
+ * `Record<string, string | string[] | undefined>`, already URL-decoded, with
22
+ * a repeated query key grouped into an array — into a `URLSearchParams` for
23
+ * {@link parseShopSearchParams}.
24
+ *
25
+ * NOT the same as `new URLSearchParams(searchParams)`: the native
26
+ * `URLSearchParams` constructor only accepts `Record<string, string>` —
27
+ * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls
28
+ * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two
29
+ * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently
30
+ * breaks `parseShopSearchParams`'s repeat-count-based array/scalar
31
+ * distinction (see its own module doc) for every multi-value `attr[<key>]`
32
+ * filter — this helper is what actually preserves the repeat.
33
+ */
34
+ declare function toURLSearchParams(searchParams: Record<string, string | string[] | undefined>): URLSearchParams;
35
+ /**
36
+ * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept
37
+ * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package
38
+ * dependency on `@reblu/site-blocks` (this package has none today, and this
39
+ * one shape does not warrant adding one). Any object matching this shape is
40
+ * assignable to `ShopFiltersHrefs` by structural typing.
41
+ */
42
+ interface ShopFilterHrefs {
43
+ taxonomy: (axisKey: string, value: string | undefined) => string;
44
+ attr: (fieldKey: string, value: string) => string;
45
+ price: (min: number | undefined, max: number | undefined) => string;
46
+ onSale: (nextActive: boolean) => string;
47
+ clearAll: () => string;
48
+ }
49
+ /**
50
+ * Build `ShopFilters`' href set from the CURRENT params and pathname. Every
51
+ * href preserves every OTHER current param (category, q, sort, every other
52
+ * taxonomy/attr filter) and resets pagination (`page`) — a filter change is
53
+ * never silently stuck on page 4 of a now-smaller result set.
54
+ *
55
+ * Pure and framework-agnostic (no `next/navigation`): a Server Component calls
56
+ * it directly with its own `searchParams` prop and route pathname — no
57
+ * `'use client'` boundary required. See `./use-shop-filter-links` for the
58
+ * client-side hook that wraps this with `usePathname`/`useSearchParams`.
59
+ */
60
+ declare function buildShopFilterHrefs(current: ShopProductsParams, pathname: string): ShopFilterHrefs;
61
+
62
+ /**
63
+ * `getShopCollection` (REB-809) — fetches the product list AND its facets with
64
+ * the EXACT SAME params in one call. This is the seam that keeps a shop
65
+ * collection page correct: facet counts must reflect the same filter context
66
+ * the product list is showing, and a hand-rolled page that calls
67
+ * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`
68
+ * separately is exactly how they drift apart.
69
+ *
70
+ * Server-safe (no `'use client'`, no React) — call it directly from an async
71
+ * Server Component (route `page.tsx`) with the params parsed via
72
+ * {@link parseShopSearchParams} from that page's `searchParams` prop.
73
+ */
74
+ interface ShopCollection {
75
+ products: ListResult<ShopProductListItem>;
76
+ facets: ShopFacets;
77
+ /**
78
+ * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;
79
+ * `'unavailable'` when it failed and `facets` above is the degraded
80
+ * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an
81
+ * empty `facets` as "this tenant configured no filterable axes" when it may
82
+ * really mean "the facets endpoint is down right now". A logged
83
+ * `console.warn` (see below) covers the case nobody checks this field.
84
+ */
85
+ facetsStatus: 'ok' | 'unavailable';
86
+ }
87
+ /**
88
+ * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT
89
+ * equally critical. A product listing with no products is not a listing —
90
+ * that failure must propagate so the caller's own error handling (or a
91
+ * thrown-away `try/catch` degrading the whole page) sees it. A product
92
+ * listing with no FACETS is still a perfectly good listing, just without
93
+ * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead
94
+ * of letting one upstream endpoint's outage take the other down too, is what
95
+ * `Promise.allSettled` (rather than `Promise.all`) buys here.
96
+ *
97
+ * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged
98
+ * `[getShopCollection]` — the same convention `@reblu/site-next` already uses
99
+ * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,
100
+ * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the
101
+ * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on
102
+ * it (retry, surface a banner, alert) do so without parsing a log line.
103
+ */
104
+ declare function getShopCollection(client: RebluClient, params?: ShopProductsParams): Promise<ShopCollection>;
105
+
106
+ export { type ShopCollection, type ShopFilterHrefs, buildShopFilterHrefs, getShopCollection, parseShopSearchParams, serializeShopParams, toURLSearchParams };