@reblu/site-blocks 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.5.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
  ## 0.4.4
4
61
 
5
62
  ### Patch Changes
package/README.md CHANGED
@@ -16,6 +16,7 @@ a different-looking site for every tenant, with zero forks.
16
16
  | `sanitizeHtml` | `.` | Sanitises an HTML string before `dangerouslySetInnerHTML` — the **HTML boundary**. See "Security boundaries" below. |
17
17
  | `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
18
18
  | `Treatment`, `Specialty`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. |
19
+ | `ShopFilters` | `.` | Plug-and-play shop filter chrome over `GET /api/site/shop/facets`. RSC-safe. See "Shop filters" below. |
19
20
  | `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
20
21
  | `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
21
22
  | `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
@@ -91,6 +92,95 @@ harden external links, and it forwards only the attributes that carry **meaning*
91
92
  - **Dropped upstream by the sanitiser, before the renderer sees it:** `reversed`
92
93
  on `<ol>`. Number a descending list explicitly with `value` on each `<li>`.
93
94
 
95
+ ## Shop filters
96
+
97
+ `ShopFilters` (REB-809) is the plug-and-play SDK component that closes a real
98
+ gap: before it existed, every SITE hand-built its own shop filter UI — the
99
+ dermamedicin SITE hardcoded `BRAND_AXIS = 'brand'` while that tenant's real
100
+ taxonomy axis key is `marca`, a bug fixed ad hoc in that SITE's own repo (PR
101
+ #69). Which axes appear, their order and their labels come ENTIRELY from
102
+ `GET /api/site/shop/facets` (nexus config — Clasificaciones "Usar como filtro
103
+ en la tienda", custom field "filtrable") — the component knows no concrete
104
+ axis or attribute.
105
+
106
+ ```tsx
107
+ import { ShopFilters } from '@reblu/site-blocks'
108
+ import {
109
+ getShopCollection,
110
+ buildShopFilterHrefs,
111
+ parseShopSearchParams,
112
+ toURLSearchParams,
113
+ } from '@reblu/site-next/shop-server'
114
+ import { client } from '@/lib/reblu'
115
+
116
+ // An async Server Component — a route's page.tsx. No 'use client' anywhere.
117
+ export default async function ShopPage({
118
+ searchParams,
119
+ }: {
120
+ searchParams: Promise<Record<string, string | string[] | undefined>>
121
+ }) {
122
+ // toURLSearchParams, NOT `new URLSearchParams(await searchParams)` — the
123
+ // native constructor only accepts Record<string, string> and silently
124
+ // comma-joins a repeated-key array value into ONE entry, which breaks
125
+ // parseShopSearchParams' repeat-count-based array/scalar distinction for
126
+ // every multi-value attr[<key>] filter. See its own doc comment.
127
+ const params = parseShopSearchParams(toURLSearchParams(await searchParams))
128
+ const { products, facets } = await getShopCollection(client, params)
129
+ const hrefs = buildShopFilterHrefs(params, '/shop')
130
+
131
+ return (
132
+ <>
133
+ <ShopFilters facets={facets} hrefs={hrefs} active={params} />
134
+ {/* render products.data */}
135
+ </>
136
+ )
137
+ }
138
+ ```
139
+
140
+ **RSC-safe by construction (REB-647).** Every option always renders a real
141
+ `<a href>` built by the `hrefs` prop — no event handler is ever attached
142
+ unless you also pass `onChange`, so the snippet above works from an actual
143
+ Server Component with zero client boundary: clicking a filter is a normal
144
+ navigation, and the server re-renders with the new `searchParams`. Pass
145
+ `onChange` only from inside a `'use client'` boundary (attaching a function
146
+ prop to a host element only works when the call site itself runs on the
147
+ client) — `@reblu/site-next/shop`'s `useShopFilterLinks()` hook is there for
148
+ that case.
149
+
150
+ **Single-select taxonomy filters, everywhere, on purpose.** `ShopFacets.
151
+ taxonomies[].selectionMode` (REB-809/REB-801) is `'single' | 'multiple'`, but
152
+ it describes how many terms a PRODUCT may carry on that axis — NOT how many
153
+ the storefront filter may select at once. `GET /api/site/shop/products`'s
154
+ `taxonomy[<key>]` query param accepts exactly one term slug per axis today
155
+ (base-api's `parseListParams` collapses any repeat), so `ShopFilters` renders
156
+ every taxonomy axis as single-select regardless of `selectionMode`: clicking
157
+ the active term's own link clears the axis, clicking another replaces it.
158
+ Wiring `selectionMode` into a true multi-value taxonomy filter needs the
159
+ query contract to grow an array form first — out of this component's scope.
160
+ Attribute facets (`facets.attributes`) are genuinely multi-select already
161
+ (`attr[<key>]` repeats to OR-match several values), so those render as a
162
+ checkbox-shaped list where more than one option may be active at once.
163
+
164
+ Price bands are a `priceBands` prop with sensible EUR defaults
165
+ (`DEFAULT_SHOP_PRICE_BANDS`) — `ShopFacets.price` only carries `{ min, max }`,
166
+ never bands, so the component never invents a business-specific breakdown.
167
+ A band's auto-generated label ("Under €25", "€25 – €50", "€100+") is built
168
+ from `formatPrice` plus three `labels` templates — `priceUnder`, `priceOver`,
169
+ `priceRange` — not hardcoded English; override them (and `currencyCode`/
170
+ `locale`) the same way you override `allTerms`/`clearAll`/`onSale`/`price`.
171
+ Clicking the currently ACTIVE band clears the price filter, the same toggle
172
+ affordance as an active taxonomy term or attribute value.
173
+
174
+ Every option toggles: clicking the active taxonomy term, the active price
175
+ band, or an active attribute value all clear that selection rather than
176
+ re-applying it as a no-op. `shouldRenderShopFilters(facets, active)` is
177
+ exported so a consuming page can decide whether to render a wrapping
178
+ landmark around `ShopFilters` without hand-copying its internal "is there
179
+ anything to show" check — true when `facets` has real content OR `active`
180
+ has a selection to clear (an active filter can legitimately collapse every
181
+ facet option to zero; the component still renders "Clear filters" then,
182
+ never stranding the visitor with no way back).
183
+
94
184
  ## Security boundaries
95
185
 
96
186
  Two sinks, two different helpers, in two different packages — do not reach for
package/dist/index.cjs CHANGED
@@ -21,10 +21,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
23
  ALLOWED_YOUTUBE_HOSTS: () => ALLOWED_YOUTUBE_HOSTS,
24
+ DEFAULT_SHOP_PRICE_BANDS: () => DEFAULT_SHOP_PRICE_BANDS,
24
25
  MarkdownRenderer: () => MarkdownRenderer,
25
26
  ProductCard: () => ProductCard,
26
27
  ProfileCard: () => ProfileCard,
27
28
  SafeHtml: () => SafeHtml,
29
+ ShopFilters: () => ShopFilters,
28
30
  Specialty: () => Specialty,
29
31
  Treatment: () => Treatment,
30
32
  buildShareUrls: () => buildShareUrls,
@@ -32,7 +34,8 @@ __export(src_exports, {
32
34
  isAllowedYouTubeHost: () => isAllowedYouTubeHost,
33
35
  markdownToSafeHtml: () => markdownToSafeHtml,
34
36
  readingTime: () => readingTime,
35
- sanitizeHtml: () => sanitizeHtml
37
+ sanitizeHtml: () => sanitizeHtml,
38
+ shouldRenderShopFilters: () => shouldRenderShopFilters
36
39
  });
37
40
  module.exports = __toCommonJS(src_exports);
38
41
 
@@ -13429,13 +13432,198 @@ function ProductCard({ product, href, currencyCode = "EUR", locale, className })
13429
13432
  }
13430
13433
  );
13431
13434
  }
13435
+
13436
+ // src/components/shop-filters.tsx
13437
+ var import_utils7 = require("@reblu/site-ui/utils");
13438
+ var import_jsx_runtime7 = require("react/jsx-runtime");
13439
+ var DEFAULT_SHOP_PRICE_BANDS = [
13440
+ { max: 2500 },
13441
+ { min: 2500, max: 5e3 },
13442
+ { min: 5e3, max: 1e4 },
13443
+ { min: 1e4 }
13444
+ ];
13445
+ var DEFAULT_LABELS = {
13446
+ allTerms: "All",
13447
+ clearAll: "Clear filters",
13448
+ onSale: "On sale",
13449
+ price: "Price",
13450
+ priceUnder: (formattedMax) => `Under ${formattedMax}`,
13451
+ priceOver: (formattedMin) => `${formattedMin}+`,
13452
+ priceRange: (formattedMin, formattedMax) => `${formattedMin} \u2013 ${formattedMax}`
13453
+ };
13454
+ function toValueArray(value) {
13455
+ if (value === void 0) return [];
13456
+ return Array.isArray(value) ? value : [value];
13457
+ }
13458
+ function hasAnyActiveFilter(active) {
13459
+ return Object.keys(active.taxonomy ?? {}).length > 0 || Object.keys(active.attr ?? {}).length > 0 || active.priceMin !== void 0 || active.priceMax !== void 0 || Boolean(active.onSale);
13460
+ }
13461
+ function shouldRenderShopFilters(facets, active = {}) {
13462
+ const hasFacetContent = facets.taxonomies.some((axis) => axis.terms.length > 0) || facets.attributes.some((attr) => attr.options.length > 0) || facets.price.max > facets.price.min || facets.onSale.count > 0;
13463
+ return hasFacetContent || hasAnyActiveFilter(active);
13464
+ }
13465
+ function priceBandLabel(band, t, currencyCode, locale) {
13466
+ if (band.label) return band.label;
13467
+ if (band.min === void 0 && band.max !== void 0) {
13468
+ return t.priceUnder(formatPrice(band.max, currencyCode, locale));
13469
+ }
13470
+ if (band.max === void 0 && band.min !== void 0) {
13471
+ return t.priceOver(formatPrice(band.min, currencyCode, locale));
13472
+ }
13473
+ if (band.min !== void 0 && band.max !== void 0) {
13474
+ return t.priceRange(formatPrice(band.min, currencyCode, locale), formatPrice(band.max, currencyCode, locale));
13475
+ }
13476
+ return "";
13477
+ }
13478
+ function OptionLink({ href, active, label, onActivate }) {
13479
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
13480
+ "a",
13481
+ {
13482
+ href,
13483
+ "aria-current": active ? "true" : void 0,
13484
+ onClick: onActivate ? (event) => {
13485
+ event.preventDefault();
13486
+ onActivate();
13487
+ } : void 0,
13488
+ className: (0, import_utils7.cn)(
13489
+ "inline-flex items-center rounded-full border px-3 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
13490
+ active ? "border-transparent bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:bg-muted hover:text-foreground"
13491
+ ),
13492
+ children: label
13493
+ }
13494
+ );
13495
+ }
13496
+ function FilterFieldset({ legend, children: children2 }) {
13497
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("fieldset", { className: "space-y-2", children: [
13498
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("legend", { className: "text-sm font-medium text-foreground", children: legend }),
13499
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { className: "flex flex-wrap gap-2", children: children2 })
13500
+ ] });
13501
+ }
13502
+ function ShopFilters({
13503
+ facets,
13504
+ hrefs,
13505
+ active = {},
13506
+ onChange,
13507
+ priceBands = DEFAULT_SHOP_PRICE_BANDS,
13508
+ labels,
13509
+ currencyCode,
13510
+ locale,
13511
+ className
13512
+ }) {
13513
+ const t = { ...DEFAULT_LABELS, ...labels };
13514
+ const visibleTaxonomies = facets.taxonomies.filter((axis) => axis.terms.length > 0);
13515
+ const visibleAttributes = facets.attributes.filter((attr) => attr.options.length > 0);
13516
+ const hasPriceSpread = facets.price.max > facets.price.min;
13517
+ const hasOnSale = facets.onSale.count > 0;
13518
+ if (!shouldRenderShopFilters(facets, active)) {
13519
+ return null;
13520
+ }
13521
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: (0, import_utils7.cn)("space-y-6", className), "data-slot": "shop-filters", children: [
13522
+ visibleTaxonomies.map((axis) => {
13523
+ const activeSlug = active.taxonomy?.[axis.key];
13524
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(FilterFieldset, { legend: axis.label, children: [
13525
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
13526
+ OptionLink,
13527
+ {
13528
+ href: hrefs.taxonomy(axis.key, void 0),
13529
+ active: activeSlug === void 0,
13530
+ label: t.allTerms,
13531
+ onActivate: onChange ? () => {
13532
+ const next2 = { ...active.taxonomy };
13533
+ delete next2[axis.key];
13534
+ onChange({ taxonomy: next2 });
13535
+ } : void 0
13536
+ }
13537
+ ),
13538
+ axis.terms.map((term) => {
13539
+ const isActive = activeSlug === term.slug;
13540
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
13541
+ OptionLink,
13542
+ {
13543
+ href: hrefs.taxonomy(axis.key, isActive ? void 0 : term.slug),
13544
+ active: isActive,
13545
+ label: `${term.name} (${term.count})`,
13546
+ onActivate: onChange ? () => {
13547
+ const next2 = { ...active.taxonomy };
13548
+ if (isActive) delete next2[axis.key];
13549
+ else next2[axis.key] = term.slug;
13550
+ onChange({ taxonomy: next2 });
13551
+ } : void 0
13552
+ },
13553
+ term.slug
13554
+ );
13555
+ })
13556
+ ] }, axis.key);
13557
+ }),
13558
+ visibleAttributes.map((attr) => {
13559
+ const activeValues = toValueArray(active.attr?.[attr.key]);
13560
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(FilterFieldset, { legend: attr.label, children: attr.options.map((opt) => {
13561
+ const isActive = activeValues.includes(opt.value);
13562
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
13563
+ OptionLink,
13564
+ {
13565
+ href: hrefs.attr(attr.key, opt.value),
13566
+ active: isActive,
13567
+ label: `${opt.label} (${opt.count})`,
13568
+ onActivate: onChange ? () => {
13569
+ const next2 = isActive ? activeValues.filter((v2) => v2 !== opt.value) : [...activeValues, opt.value];
13570
+ const attrNext = { ...active.attr };
13571
+ if (next2.length === 0) delete attrNext[attr.key];
13572
+ else attrNext[attr.key] = next2;
13573
+ onChange({ attr: attrNext });
13574
+ } : void 0
13575
+ },
13576
+ opt.value
13577
+ );
13578
+ }) }, attr.key);
13579
+ }),
13580
+ hasPriceSpread && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(FilterFieldset, { legend: t.price, children: priceBands.map((band, i) => {
13581
+ const isActive = active.priceMin === band.min && active.priceMax === band.max;
13582
+ const nextMin = isActive ? void 0 : band.min;
13583
+ const nextMax = isActive ? void 0 : band.max;
13584
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
13585
+ OptionLink,
13586
+ {
13587
+ href: hrefs.price(nextMin, nextMax),
13588
+ active: isActive,
13589
+ label: priceBandLabel(band, t, currencyCode, locale),
13590
+ onActivate: onChange ? () => onChange({ priceMin: nextMin, priceMax: nextMax }) : void 0
13591
+ },
13592
+ i
13593
+ );
13594
+ }) }),
13595
+ hasOnSale && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
13596
+ OptionLink,
13597
+ {
13598
+ href: hrefs.onSale(!active.onSale),
13599
+ active: Boolean(active.onSale),
13600
+ label: `${t.onSale} (${facets.onSale.count})`,
13601
+ onActivate: onChange ? () => onChange({ onSale: !active.onSale }) : void 0
13602
+ }
13603
+ ),
13604
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
13605
+ "a",
13606
+ {
13607
+ href: hrefs.clearAll(),
13608
+ onClick: onChange ? (event) => {
13609
+ event.preventDefault();
13610
+ onChange({ taxonomy: {}, attr: {}, priceMin: void 0, priceMax: void 0, onSale: false });
13611
+ } : void 0,
13612
+ className: "text-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline",
13613
+ children: t.clearAll
13614
+ }
13615
+ )
13616
+ ] });
13617
+ }
13432
13618
  // Annotate the CommonJS export names for ESM import in node:
13433
13619
  0 && (module.exports = {
13434
13620
  ALLOWED_YOUTUBE_HOSTS,
13621
+ DEFAULT_SHOP_PRICE_BANDS,
13435
13622
  MarkdownRenderer,
13436
13623
  ProductCard,
13437
13624
  ProfileCard,
13438
13625
  SafeHtml,
13626
+ ShopFilters,
13439
13627
  Specialty,
13440
13628
  Treatment,
13441
13629
  buildShareUrls,
@@ -13443,6 +13631,7 @@ function ProductCard({ product, href, currencyCode = "EUR", locale, className })
13443
13631
  isAllowedYouTubeHost,
13444
13632
  markdownToSafeHtml,
13445
13633
  readingTime,
13446
- sanitizeHtml
13634
+ sanitizeHtml,
13635
+ shouldRenderShopFilters
13447
13636
  });
13448
13637
  //# sourceMappingURL=index.cjs.map