@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 +57 -0
- package/README.md +69 -0
- package/dist/shop-server.cjs +198 -0
- package/dist/shop-server.cjs.map +1 -0
- package/dist/shop-server.d.cts +106 -0
- package/dist/shop-server.d.ts +106 -0
- package/dist/shop-server.js +167 -0
- package/dist/shop-server.js.map +1 -0
- package/dist/shop.cjs +212 -0
- package/dist/shop.cjs.map +1 -0
- package/dist/shop.d.cts +20 -0
- package/dist/shop.d.ts +20 -0
- package/dist/shop.js +181 -0
- package/dist/shop.js.map +1 -0
- package/package.json +23 -3
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 };
|