@reblu/site-blocks 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,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