@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/CHANGELOG.md +736 -0
- package/README.md +90 -0
- package/dist/index.cjs +191 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +108 -2
- package/dist/index.d.ts +108 -2
- package/dist/index.js +187 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
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
|