@reblu/site-blocks 0.13.0 → 0.15.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 +21 -0
- package/README.md +230 -9
- package/dist/client.cjs +259 -0
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +76 -1
- package/dist/client.d.ts +76 -1
- package/dist/client.js +256 -0
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +592 -306
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +152 -10
- package/dist/index.d.ts +152 -10
- package/dist/index.js +585 -305
- package/dist/index.js.map +1 -1
- package/dist/seo.cjs +13034 -12
- package/dist/seo.cjs.map +1 -1
- package/dist/seo.d.cts +22 -1
- package/dist/seo.d.ts +22 -1
- package/dist/seo.js +13032 -5
- package/dist/seo.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# @reblu/site-blocks
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cc4e14e: REB-980: add `DataRequestForm` to `@reblu/site-blocks/client` — the drop-in public GDPR rights form (request type, name, email, optional message, consent checkbox). Requires a reCAPTCHA token from `getCaptchaToken` and never submits without one or without consent; shows the 72h response deadline in a `role="status"` region on success. Rejections are mapped by status to fixed, overridable copy (`400` validation, `422` captcha, `429` rate limit, otherwise generic) so no server text reaches the visitor. Default transport POSTs JSON to the SITE's `/api/data-requests` Route Handler (`endpoint`, or a custom `submit`). Also exports `DATA_REQUEST_TYPES` and `DEFAULT_DATA_REQUEST_FORM_LABELS`. README: page + Route Handler snippet.
|
|
8
|
+
- 2273e3c: Add `TrustBadges` — the drop-in for the tenant's trust badges from `client.trustBadges.list()` (REB-996). Server Component: groups badges by type under overridable headings (`DEFAULT_TRUST_BADGE_HEADINGS`, `SUBVENTION` → «Financiación»; `null` hides one), `types` filters and orders the groups, logos are images with `alt` = badge name, an optional http(s) link opens in a new window with `rel="noopener noreferrer"` and a visually hidden hint, and nothing is rendered when there are no badges. README: footer snippet.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- f48712a: Consumer classNames keep custom theme tokens (REB-997). `CourseDescription`'s `headingClassName` / `contentClassName` and `ProductTrustItems`' `itemClassName` now replace their slot's stylistic defaults instead of being merged over them, and the consumer's `text-*` classes on `CourseDescription`, `ProductTrustItems`, `MarkdownRenderer` and `SafeHtml` reach the DOM verbatim — tailwind-merge read a custom font-size token such as `text-h2` as a colour and dropped it. Rendering without those props is unchanged.
|
|
13
|
+
- Updated dependencies [cc4e14e]
|
|
14
|
+
- @reblu/site-contracts@1.16.0
|
|
15
|
+
|
|
16
|
+
## 0.14.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- fff0bf5: REB-981: `CartAdjustmentsNotice` — presentational notice for what cart validation changed (removed lines, clamped quantities, changed prices), fed by `@reblu/site-next/cart`'s `useCartValidation()`. `CartLineList` gains an optional `lineMessage` prop to render a message under a line (e.g. its validation error). README replaces the `errors[0].message` guidance.
|
|
21
|
+
- b7667d4: Add `CourseDescription` — the drop-in for the rich-text course `description` from `client.academy.bySlug()` (REB-879). Server Component: sanitised Markdown (headings, GFM tables, images) through the `MarkdownRenderer` pipeline; renders nothing, not even its heading, when the description is null, blank or emptied by the sanitiser; an optional `heading` makes it a labelled `<section>` with the description's own headings demoted under it. README: snippet for the course page.
|
|
22
|
+
- b7667d4: Add `markdownToPlainText(markdown, { maxLength })` (root and `./seo` entries): rich-text Markdown → the plain text a reader sees, for meta/OG descriptions and excerpts. It runs the same sanitised pipeline as `MarkdownRenderer`, drops images, and cuts at a word boundary with `…`. README: how to render a rich-text course description and build its meta description (REB-879).
|
|
23
|
+
|
|
3
24
|
## 0.13.0
|
|
4
25
|
|
|
5
26
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -8,23 +8,35 @@ their own. Every color, radius and font comes from CSS design tokens (OKLCH)
|
|
|
8
8
|
that each tenant injects through its `site-theme.css`. The same package renders
|
|
9
9
|
a different-looking site for every tenant, with zero forks.
|
|
10
10
|
|
|
11
|
+
**Required theme tokens.** Besides the usual shadcn tokens, the consuming
|
|
12
|
+
theme must define `--destructive-foreground` (text colour on `--destructive`,
|
|
13
|
+
≥ 4.5:1 contrast) and map it in `@theme inline` as
|
|
14
|
+
`--color-destructive-foreground: var(--destructive-foreground);`. Tailwind v4
|
|
15
|
+
emits no CSS for `text-destructive-foreground` without it, so error states
|
|
16
|
+
(e.g. `AddToCartButton`'s `error` status) would render with inherited text
|
|
17
|
+
colour on the red background. See `docs/theming/theming.md`.
|
|
18
|
+
|
|
11
19
|
## What's inside
|
|
12
20
|
|
|
13
21
|
| Export | Entry | Purpose |
|
|
14
22
|
| ------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
15
23
|
| `readingTime`, `formatPrice`, `buildShareUrls`, `markdownToSafeHtml` | `.` | Pure, framework-agnostic helpers (RSC-safe). |
|
|
24
|
+
| `markdownToPlainText` | `.` | Markdown → plain text for meta/OG descriptions (also on `./seo`). Same sanitiser as `MarkdownRenderer`. |
|
|
16
25
|
| `sanitizeHtml` | `.` | Sanitises an HTML string before `dangerouslySetInnerHTML` — the **HTML boundary**. See "Security boundaries" below. |
|
|
17
26
|
| `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
|
|
27
|
+
| `CourseDescription` | `.` | Drop-in for the rich-text course `description` (`client.academy.bySlug()`). Server Component. See "Rich-text descriptions". |
|
|
18
28
|
| `Treatment`, `ParentPage`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. `Specialty`: deprecated alias, see below. |
|
|
19
29
|
| `ShopFilters` | `.` | Plug-and-play shop filter chrome over `GET /api/site/shop/facets`. RSC-safe. See "Shop filters" below. |
|
|
20
|
-
| `CartLineList`, `CartSummary`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
|
|
30
|
+
| `CartLineList`, `CartSummary`, `CartAdjustmentsNotice`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
|
|
21
31
|
| `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
|
|
22
32
|
| `ProductTrustItems`, `DEFAULT_TRUST_ICONS` | `.` | Store-wide trust messages from `ShopConfig.productTrustItems`. Server Component. See "Product trust items" below. |
|
|
33
|
+
| `TrustBadges`, `DEFAULT_TRUST_BADGE_HEADINGS` | `.` | The tenant's trust badges from `client.trustBadges.list()`, grouped by type. Server Component. See "Trust badges" below. |
|
|
23
34
|
| `buildFaqPageJsonLd`, `collectFaqItems` | `./seo` | schema.org `FAQPage` structured data. Directive-free (also on `.`). See "Page blocks" below. |
|
|
24
35
|
| `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
|
|
25
36
|
| `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
|
|
26
37
|
| `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
|
|
27
38
|
| `AddToCartButton`, `CartIcon` | `./client` | Add-to-cart feedback (REB-837) — **moved here from `.`**, see below. |
|
|
39
|
+
| `DataRequestForm` | `./client` | Drop-in public GDPR rights form over `POST /api/site/data-requests` (REB-980). See "GDPR rights form" below. |
|
|
28
40
|
|
|
29
41
|
## Boundary
|
|
30
42
|
|
|
@@ -32,6 +44,35 @@ a different-looking site for every tenant, with zero forks.
|
|
|
32
44
|
- **`@reblu/site-blocks`** = organisms + headless data hooks. Higher churn — its
|
|
33
45
|
own package so a breaking change in an organism does not bump the stable atoms.
|
|
34
46
|
|
|
47
|
+
## Styling a drop-in — the className rule (REB-997)
|
|
48
|
+
|
|
49
|
+
`cn` (tailwind-merge) only knows Tailwind's default scale. A SITE's own theme
|
|
50
|
+
token such as `text-h2` (a font size) is read as a text **colour**, so
|
|
51
|
+
`twMerge('text-xl font-semibold', 'text-h2 text-ink')` keeps `text-xl` and
|
|
52
|
+
drops `text-h2`. To keep custom tokens working, the typography slot props
|
|
53
|
+
follow one rule:
|
|
54
|
+
|
|
55
|
+
- **A slot className replaces that slot's stylistic defaults.** Passing
|
|
56
|
+
`CourseDescription`'s `headingClassName` drops its `text-xl font-semibold`;
|
|
57
|
+
`contentClassName` drops `text-muted-foreground`; `ProductTrustItems`'
|
|
58
|
+
`itemClassName` drops `text-sm text-muted-foreground`. Pass the full
|
|
59
|
+
typography you want for that slot.
|
|
60
|
+
- **Structural classes stay.** Layout an element needs (the trust row's
|
|
61
|
+
`flex items-center gap-3`, the `space-y-*` of a section or list, the
|
|
62
|
+
`max-w-none` of `MarkdownRenderer` / `SafeHtml`) is kept, and your layout
|
|
63
|
+
classes still override it through `cn` (`max-w-prose` beats `max-w-none`).
|
|
64
|
+
- **Your `text-*` classes reach the DOM verbatim** on those elements
|
|
65
|
+
(`CourseDescription`, `ProductTrustItems`, `MarkdownRenderer`, `SafeHtml`):
|
|
66
|
+
they never go through tailwind-merge, so `text-h2 text-ink` renders as both.
|
|
67
|
+
- **Without the prop, nothing changes** — the defaults render exactly as before.
|
|
68
|
+
|
|
69
|
+
The root `className` of interactive or boxed drop-ins (`AddToCartButton`,
|
|
70
|
+
`CartIcon`, `CartAdjustmentsNotice`, cards, toasts, the login prompt) is still
|
|
71
|
+
merged with `cn` over their defaults: those defaults pair a text colour with
|
|
72
|
+
its background, and a root className is mostly used for spacing. Put a custom
|
|
73
|
+
font-size token there on an element of your own (or wrap the drop-in) rather
|
|
74
|
+
than on the root className.
|
|
75
|
+
|
|
35
76
|
## Authoring notes
|
|
36
77
|
|
|
37
78
|
What `MarkdownRenderer` does with tenant-authored Markdown and HTML. These are
|
|
@@ -199,7 +240,8 @@ for the wiring steps and a full example app).
|
|
|
199
240
|
| `AddToCartButton` | `./client` | `onAddToCart?`, `disabled?`, `outOfStock?`, `pending?`, `error?`, `item?`, `successDurationMs?`, `onAdded?`, `onError?` | No — carries a built-in confirmed/error timer (`useState`/`useEffect`), see below |
|
|
200
241
|
| `CartIcon` | `./client` | `itemCount`, `href`, `hydrated?`, `bumpOnAdd?` | No — the bump animation needs state, see below |
|
|
201
242
|
| `AddedToCartToast` | `.` | `productName`, `cartHref`, `status?`, `quantity?`, `imageUrl?` | Yes |
|
|
202
|
-
| `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?`
|
|
243
|
+
| `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?`, `lineMessage?` | Yes |
|
|
244
|
+
| `CartAdjustmentsNotice` | `.` | `removed?`, `quantityChanges?`, `priceChanges?`, `labels?` | Yes |
|
|
203
245
|
| `CartSummary` | `.` | `subtotal`, `discount?`, `shipping?`, `shippingEnabled?`, `total`, `notice?` | Yes |
|
|
204
246
|
| `CheckoutLoginPrompt` | `.` | `open`, `email`, `onEmailChange`, `onClose`, `onSubmitEmail`, `onContinueAsGuest` | Yes (renders `null` when `open` is false) |
|
|
205
247
|
| `CheckoutForm` | `.` | `contact`, `onContactChange`, `discountCode`, `onDiscountCodeChange`, `onApplyDiscount`, `onRemoveDiscount`, `onSubmit` | Yes |
|
|
@@ -352,12 +394,36 @@ For malformed bodies (which parse as `undefined`), pass a generic body such as
|
|
|
352
394
|
`error.body ?? { error: 'Checkout failed' }` to show the fallback banner.
|
|
353
395
|
An external SITE (including Dermamedicin) must adopt this wiring separately.
|
|
354
396
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
`
|
|
397
|
+
Do not surface validate-cart's rejection as the first
|
|
398
|
+
`ShopCartValidationError.message`: it does not say which line failed, and a
|
|
399
|
+
deleted product only comes back as «Producto desconocido». Use
|
|
400
|
+
`@reblu/site-next/cart`'s `useCartValidation()` (REB-981) on the cart and
|
|
401
|
+
checkout pages: it removes unavailable lines, clamps quantities and refreshes
|
|
402
|
+
prices in the store, and its result feeds `CartAdjustmentsNotice` and
|
|
403
|
+
`CartLineList`'s `lineMessage` — see "Cart adjustments" below and that
|
|
404
|
+
package's README.
|
|
405
|
+
|
|
406
|
+
### Cart adjustments — `CartAdjustmentsNotice` (REB-981)
|
|
407
|
+
|
|
408
|
+
Presentational: tells the visitor what cart validation changed. Every prop is
|
|
409
|
+
optional and renders nothing when empty, so it can stay mounted.
|
|
410
|
+
|
|
411
|
+
```tsx
|
|
412
|
+
<CartAdjustmentsNotice
|
|
413
|
+
removed={validation.removed} // [{ name, variantName? }]
|
|
414
|
+
quantityChanges={validation.quantityChanges} // [{ name, variantName?, to }]
|
|
415
|
+
priceChanges={validation.priceChanges} // [{ name, variantName?, from, to }] cents
|
|
416
|
+
/>
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Default copy: «Hemos quitado del carrito: X, ya no está disponible.» (plural
|
|
420
|
+
«…ya no están disponibles.»), «Hemos ajustado la cantidad de X a N por el
|
|
421
|
+
stock disponible.», «El precio de X ha cambiado: antes A, ahora B.». Override
|
|
422
|
+
any of them with `labels={{ removed, quantityChanged, priceChanged }}` (each a
|
|
423
|
+
function). `currencyCode`/`locale` format the prices.
|
|
424
|
+
|
|
425
|
+
`CartLineList`'s `lineMessage={(item) => …}` renders a message under a line,
|
|
426
|
+
e.g. `validation.getLineError(item.productId, item.variantId)?.message`.
|
|
361
427
|
|
|
362
428
|
### `OrderConfirmation` states (REB-837)
|
|
363
429
|
|
|
@@ -511,7 +577,9 @@ export default async function ProductPage() {
|
|
|
511
577
|
A semantic `<ul>` (or `<ol>` via `as='ol'`) of `<li>` rows; each icon is
|
|
512
578
|
`aria-hidden` because the text carries the meaning. Colours come from theme
|
|
513
579
|
tokens only (`text-muted-foreground`, `text-foreground/60`). `className` styles
|
|
514
|
-
the list, `itemClassName` each row
|
|
580
|
+
the list, `itemClassName` each row — it **replaces** the row's default
|
|
581
|
+
`text-sm text-muted-foreground` and keeps its `flex items-center gap-3` layout
|
|
582
|
+
(REB-997).
|
|
515
583
|
|
|
516
584
|
### Overriding icons
|
|
517
585
|
|
|
@@ -531,6 +599,45 @@ import { Truck } from 'my-icon-set'
|
|
|
531
599
|
`icons` cannot add a new icon key: an item whose `icon` this version does not
|
|
532
600
|
know is still skipped.
|
|
533
601
|
|
|
602
|
+
## Trust badges
|
|
603
|
+
|
|
604
|
+
`TrustBadges` renders the tenant's trust badges — partners, insurers, funding,
|
|
605
|
+
certifications, awards — managed in nexus (`/trust-badges`) and served by
|
|
606
|
+
`client.trustBadges.list()` (REB-996). The API only returns badges that are
|
|
607
|
+
switched on AND inside their optional validity window («Visible desde» /
|
|
608
|
+
«Visible hasta», whole days in the tenant's time zone), so toggling or expiring
|
|
609
|
+
a badge in nexus removes it from the SITE on the next revalidation, with no
|
|
610
|
+
deploy and no SITE change.
|
|
611
|
+
|
|
612
|
+
```tsx
|
|
613
|
+
import { TrustBadges } from '@reblu/site-blocks'
|
|
614
|
+
|
|
615
|
+
export async function Footer() {
|
|
616
|
+
const { data: badges } = await client.trustBadges.list().catch(() => ({ data: [] }))
|
|
617
|
+
return (
|
|
618
|
+
<footer>
|
|
619
|
+
{/* … */}
|
|
620
|
+
<TrustBadges badges={badges} headingLevel={2} />
|
|
621
|
+
</footer>
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
- Groups by type, each under a heading from `DEFAULT_TRUST_BADGE_HEADINGS`
|
|
627
|
+
(`SUBVENTION` → «Financiación», `CERTIFICATION` → «Certificaciones»,
|
|
628
|
+
`AWARD` → «Premios», `PARTNER` → «Colaboradores», `INSURANCE` →
|
|
629
|
+
«Aseguradoras»). Override any with `headings`; `null` hides one.
|
|
630
|
+
- `types` picks which groups render and in what order, e.g. only the funding
|
|
631
|
+
logos: `<TrustBadges badges={badges} types={['SUBVENTION']} />`. To fetch
|
|
632
|
+
only one type, `client.trustBadges.list({ type: 'SUBVENTION' })`.
|
|
633
|
+
- A logo is an `<img>` with `alt` = badge name; a badge without a logo shows
|
|
634
|
+
its name as text. A badge `url` (http/https only) opens in a new window with
|
|
635
|
+
`rel="noopener noreferrer"` and a visually hidden «(se abre en una ventana
|
|
636
|
+
nueva)» — override it with `newWindowLabel`.
|
|
637
|
+
- `null`, `undefined` or an empty list render nothing — never an empty heading.
|
|
638
|
+
- Styling hooks: `className`, `groupClassName`, `headingClassName`,
|
|
639
|
+
`listClassName`, `logoClassName` (e.g. `brightness-0 invert` on a dark footer).
|
|
640
|
+
|
|
534
641
|
## `ParentPage` (formerly `Specialty`)
|
|
535
642
|
|
|
536
643
|
`ParentPage` renders a page that groups other pages: the hero and content
|
|
@@ -699,6 +806,120 @@ called on the server (REB-846). `test/seo-entry.test.ts` guards this on the
|
|
|
699
806
|
emitted `dist`. The caller serialises; `@reblu/site-next`'s `<JsonLd>` escapes
|
|
700
807
|
the `</script>` boundary.
|
|
701
808
|
|
|
809
|
+
### Rich-text descriptions on a page (`CourseDescription` + `markdownToPlainText`)
|
|
810
|
+
|
|
811
|
+
Some API fields are rich-text Markdown written in nexus with the shared editor
|
|
812
|
+
(headings, GFM tables, images) — for example a course `description` from
|
|
813
|
+
`client.academy.bySlug()` (REB-879). Never print them as plain text and never
|
|
814
|
+
pass them to `dangerouslySetInnerHTML`. On the course page, drop in
|
|
815
|
+
`CourseDescription`; for the meta description use `markdownToPlainText`, never a
|
|
816
|
+
slice of the raw Markdown (that leaks `##`, table pipes and `` into search
|
|
817
|
+
snippets):
|
|
818
|
+
|
|
819
|
+
```tsx
|
|
820
|
+
import { CourseDescription } from '@reblu/site-blocks'
|
|
821
|
+
import { markdownToPlainText } from '@reblu/site-blocks/seo'
|
|
822
|
+
|
|
823
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
824
|
+
const course = await client.academy.bySlug((await params).slug)
|
|
825
|
+
const description =
|
|
826
|
+
course.shortDescription || markdownToPlainText(course.description, { maxLength: 160 })
|
|
827
|
+
return { description: description || undefined }
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export default async function CoursePage({ params }: Props) {
|
|
831
|
+
const course = await client.academy.bySlug((await params).slug)
|
|
832
|
+
return (
|
|
833
|
+
<article>
|
|
834
|
+
<h1>{course.title}</h1>
|
|
835
|
+
{/* The heading is your own copy, in your SITE's language. */}
|
|
836
|
+
<CourseDescription description={course.description} heading='Sobre el curso' />
|
|
837
|
+
</article>
|
|
838
|
+
)
|
|
839
|
+
}
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
`CourseDescription` (Server Component, RSC-safe):
|
|
843
|
+
|
|
844
|
+
- runs the Markdown through the same sanitised pipeline as `MarkdownRenderer`
|
|
845
|
+
(`<script>`, event handlers, `javascript:` links and non-YouTube iframes are
|
|
846
|
+
dropped);
|
|
847
|
+
- renders **nothing**, not even the heading, for `null`, blank, or content the
|
|
848
|
+
sanitiser leaves empty, so a page never shows an empty "About the course";
|
|
849
|
+
- with `heading`, renders a `<section aria-labelledby>` and demotes the
|
|
850
|
+
description's own headings under it (`headingLevel`, default `2`; override with
|
|
851
|
+
`headingOffset`; `headingId` when the page renders it twice);
|
|
852
|
+
- styling hooks: `className`, `headingClassName`, `contentClassName`, and
|
|
853
|
+
`data-slot='course-description'` on the outer element. `headingClassName`
|
|
854
|
+
and `contentClassName` **replace** the default `text-xl font-semibold` /
|
|
855
|
+
`text-muted-foreground` (see [the className rule](#styling-a-drop-in--the-classname-rule-reb-997)).
|
|
856
|
+
|
|
857
|
+
For any other rich-text Markdown field, `MarkdownRenderer` is the generic
|
|
858
|
+
renderer (`headingOffset` demotes its headings under yours).
|
|
859
|
+
`markdownToPlainText` drops images and anything the sanitiser strips, and cuts
|
|
860
|
+
at a word boundary with `…` within `maxLength`.
|
|
861
|
+
|
|
862
|
+
## GDPR rights form — `DataRequestForm` (REB-980)
|
|
863
|
+
|
|
864
|
+
Drop-in form for a SITE's public rights page (e.g. `/derechos-rgpd`): request
|
|
865
|
+
type, name, email, optional message and a consent checkbox. A submission lands
|
|
866
|
+
in the tenant's nexus queue (`/settings/data-requests`) with source
|
|
867
|
+
`PUBLIC_FORM` and its 72h clock running; the form then shows the response
|
|
868
|
+
deadline (`slaDueAt`) in a `role="status"` region.
|
|
869
|
+
|
|
870
|
+
```tsx
|
|
871
|
+
'use client'
|
|
872
|
+
import { useCallback, useState } from 'react'
|
|
873
|
+
import { DataRequestForm } from '@reblu/site-blocks/client'
|
|
874
|
+
import { RECAPTCHA_DATA_REQUEST_ACTION } from '@reblu/site-next/recaptcha'
|
|
875
|
+
import {
|
|
876
|
+
DeferredRecaptchaProvider,
|
|
877
|
+
useDeferredRecaptchaToken,
|
|
878
|
+
} from '@reblu/site-next/recaptcha-provider'
|
|
879
|
+
|
|
880
|
+
function Form({ onArm }: { onArm: () => void }) {
|
|
881
|
+
const getCaptchaToken = useDeferredRecaptchaToken(RECAPTCHA_DATA_REQUEST_ACTION, { onArm })
|
|
882
|
+
return <DataRequestForm getCaptchaToken={getCaptchaToken} onInteract={onArm} />
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export function DataRequestSection({ siteKey }: { siteKey: string }) {
|
|
886
|
+
const [armed, setArmed] = useState(false)
|
|
887
|
+
const arm = useCallback(() => setArmed(true), [])
|
|
888
|
+
return (
|
|
889
|
+
<DeferredRecaptchaProvider siteKey={siteKey} armed={armed}>
|
|
890
|
+
<Form onArm={arm} />
|
|
891
|
+
</DeferredRecaptchaProvider>
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
```ts
|
|
897
|
+
// app/api/data-requests/route.ts — server side, the API key never reaches the browser
|
|
898
|
+
import { createRebluClient } from '@reblu/site-client'
|
|
899
|
+
|
|
900
|
+
export async function POST(request: Request) {
|
|
901
|
+
const client = createRebluClient({ baseUrl: process.env.REBLU_API_BASE_URL!, apiKey: process.env.REBLU_API_KEY! })
|
|
902
|
+
try {
|
|
903
|
+
return Response.json({ data: await client.dataRequests.submit(await request.json()) })
|
|
904
|
+
} catch (e) {
|
|
905
|
+
return Response.json({ error: 'rejected' }, { status: (e as { status?: number }).status ?? 502 })
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
- **reCAPTCHA is required.** base-api rejects a request without a token, so the
|
|
911
|
+
form never submits when `getCaptchaToken` resolves `undefined`.
|
|
912
|
+
- **No server text reaches the visitor.** Rejections are mapped by status to
|
|
913
|
+
fixed copy: `400` → `validationError`, `422` → `captchaError`, `429` →
|
|
914
|
+
`rateLimitError`, anything else (or no answer) → `genericError`.
|
|
915
|
+
- **Transport.** By default the form POSTs JSON to `endpoint`
|
|
916
|
+
(`/api/data-requests`) and reads the `{ data }` envelope. Pass `submit` to
|
|
917
|
+
use your own transport; it returns `{ ok: true, data }` or `{ ok: false, status }`.
|
|
918
|
+
- **Copy** defaults to Spanish; override any string with `labels` (including
|
|
919
|
+
`requestTypes` per right and `success(deadline)`), the date locale with
|
|
920
|
+
`locale`, and the offered rights with `requestTypes`.
|
|
921
|
+
- `onSubmitted(ack)` fires after success — e.g. for analytics.
|
|
922
|
+
|
|
702
923
|
## Security boundaries
|
|
703
924
|
|
|
704
925
|
Two sinks, two different helpers, in two different packages — do not reach for
|
package/dist/client.cjs
CHANGED
|
@@ -34,6 +34,9 @@ __export(client_exports, {
|
|
|
34
34
|
AddToCartButton: () => AddToCartButton,
|
|
35
35
|
CartIcon: () => CartIcon,
|
|
36
36
|
Collection: () => Collection,
|
|
37
|
+
DATA_REQUEST_TYPES: () => DATA_REQUEST_TYPES,
|
|
38
|
+
DEFAULT_DATA_REQUEST_FORM_LABELS: () => DEFAULT_DATA_REQUEST_FORM_LABELS,
|
|
39
|
+
DataRequestForm: () => DataRequestForm,
|
|
37
40
|
ShareButtons: () => ShareButtons,
|
|
38
41
|
useCollection: () => useCollection
|
|
39
42
|
});
|
|
@@ -522,11 +525,267 @@ function CartIcon({ itemCount, href, hydrated, bumpOnAdd, labels, className }) {
|
|
|
522
525
|
}
|
|
523
526
|
);
|
|
524
527
|
}
|
|
528
|
+
|
|
529
|
+
// src/components/data-request-form.tsx
|
|
530
|
+
var import_react4 = require("react");
|
|
531
|
+
var import_utils5 = require("@reblu/site-ui/utils");
|
|
532
|
+
var import_jsx_runtime5 = require("react/jsx-runtime");
|
|
533
|
+
var DATA_REQUEST_TYPES = [
|
|
534
|
+
"ACCESS",
|
|
535
|
+
"RECTIFICATION",
|
|
536
|
+
"ERASURE",
|
|
537
|
+
"OBJECTION",
|
|
538
|
+
"PORTABILITY",
|
|
539
|
+
"RESTRICTION",
|
|
540
|
+
"AUTOMATED_DECISION",
|
|
541
|
+
"INFORMATION"
|
|
542
|
+
];
|
|
543
|
+
var DEFAULT_DATA_REQUEST_FORM_LABELS = {
|
|
544
|
+
requestTypeLabel: "Derecho que quieres ejercer",
|
|
545
|
+
requestTypePlaceholder: "Selecciona una opci\xF3n",
|
|
546
|
+
requestTypes: {
|
|
547
|
+
ACCESS: "Acceso",
|
|
548
|
+
RECTIFICATION: "Rectificaci\xF3n",
|
|
549
|
+
ERASURE: "Supresi\xF3n",
|
|
550
|
+
OBJECTION: "Oposici\xF3n",
|
|
551
|
+
PORTABILITY: "Portabilidad",
|
|
552
|
+
RESTRICTION: "Limitaci\xF3n del tratamiento",
|
|
553
|
+
AUTOMATED_DECISION: "No ser objeto de decisiones automatizadas",
|
|
554
|
+
INFORMATION: "Informaci\xF3n"
|
|
555
|
+
},
|
|
556
|
+
nameLabel: "Nombre y apellidos",
|
|
557
|
+
emailLabel: "Email",
|
|
558
|
+
detailsLabel: "Mensaje (opcional)",
|
|
559
|
+
detailsPlaceholder: "Describe tu solicitud",
|
|
560
|
+
consent: "Acepto que mis datos se traten para gestionar esta solicitud.",
|
|
561
|
+
submit: "Enviar solicitud",
|
|
562
|
+
submitting: "Enviando\u2026",
|
|
563
|
+
requestTypeRequired: "Elige el derecho que quieres ejercer.",
|
|
564
|
+
nameRequired: "Indica tu nombre.",
|
|
565
|
+
emailInvalid: "Introduce un email v\xE1lido.",
|
|
566
|
+
consentRequired: "Debes aceptar el tratamiento de tus datos para gestionar la solicitud.",
|
|
567
|
+
success: (deadline) => /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_jsx_runtime5.Fragment, { children: [
|
|
568
|
+
"Hemos recibido tu solicitud. Te responderemos como m\xE1ximo el ",
|
|
569
|
+
deadline,
|
|
570
|
+
"."
|
|
571
|
+
] }),
|
|
572
|
+
captchaError: "No hemos podido verificar que no eres un robot. Recarga la p\xE1gina e int\xE9ntalo de nuevo.",
|
|
573
|
+
validationError: "Revisa los datos del formulario e int\xE9ntalo de nuevo.",
|
|
574
|
+
rateLimitError: "Has enviado demasiadas solicitudes. Espera unos minutos e int\xE9ntalo de nuevo.",
|
|
575
|
+
genericError: "No hemos podido enviar tu solicitud. Int\xE9ntalo de nuevo m\xE1s tarde."
|
|
576
|
+
};
|
|
577
|
+
var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
578
|
+
async function postToRoute(endpoint, input) {
|
|
579
|
+
try {
|
|
580
|
+
const res = await fetch(endpoint, {
|
|
581
|
+
method: "POST",
|
|
582
|
+
headers: { "Content-Type": "application/json" },
|
|
583
|
+
body: JSON.stringify(input)
|
|
584
|
+
});
|
|
585
|
+
if (!res.ok) return { ok: false, status: res.status };
|
|
586
|
+
const json = await res.json();
|
|
587
|
+
return json.data ? { ok: true, data: json.data } : { ok: false, status: res.status };
|
|
588
|
+
} catch {
|
|
589
|
+
return { ok: false, status: 0 };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function rejectionMessage(status, t) {
|
|
593
|
+
if (status === 400) return t.validationError;
|
|
594
|
+
if (status === 422) return t.captchaError;
|
|
595
|
+
if (status === 429) return t.rateLimitError;
|
|
596
|
+
return t.genericError;
|
|
597
|
+
}
|
|
598
|
+
var FIELD_CLASS = "w-full rounded-lg border bg-background px-3 py-2 text-sm transition focus:outline-none focus:ring-2 focus:ring-primary/20";
|
|
599
|
+
function DataRequestForm({
|
|
600
|
+
getCaptchaToken,
|
|
601
|
+
submit,
|
|
602
|
+
endpoint = "/api/data-requests",
|
|
603
|
+
onInteract,
|
|
604
|
+
onSubmitted,
|
|
605
|
+
requestTypes = DATA_REQUEST_TYPES,
|
|
606
|
+
labels,
|
|
607
|
+
locale = "es-ES",
|
|
608
|
+
className
|
|
609
|
+
}) {
|
|
610
|
+
const t = {
|
|
611
|
+
...DEFAULT_DATA_REQUEST_FORM_LABELS,
|
|
612
|
+
...labels,
|
|
613
|
+
requestTypes: { ...DEFAULT_DATA_REQUEST_FORM_LABELS.requestTypes, ...labels?.requestTypes }
|
|
614
|
+
};
|
|
615
|
+
const id = (0, import_react4.useId)();
|
|
616
|
+
const [requestType, setRequestType] = (0, import_react4.useState)("");
|
|
617
|
+
const [name, setName] = (0, import_react4.useState)("");
|
|
618
|
+
const [email, setEmail] = (0, import_react4.useState)("");
|
|
619
|
+
const [details, setDetails] = (0, import_react4.useState)("");
|
|
620
|
+
const [consent, setConsent] = (0, import_react4.useState)(false);
|
|
621
|
+
const [errors, setErrors] = (0, import_react4.useState)({});
|
|
622
|
+
const [formError, setFormError] = (0, import_react4.useState)(null);
|
|
623
|
+
const [submitting, setSubmitting] = (0, import_react4.useState)(false);
|
|
624
|
+
const [ack, setAck] = (0, import_react4.useState)(null);
|
|
625
|
+
async function handleSubmit(event) {
|
|
626
|
+
event.preventDefault();
|
|
627
|
+
if (submitting) return;
|
|
628
|
+
setFormError(null);
|
|
629
|
+
const next = {};
|
|
630
|
+
if (!requestType) next.requestType = t.requestTypeRequired;
|
|
631
|
+
if (!name.trim()) next.name = t.nameRequired;
|
|
632
|
+
if (!EMAIL_PATTERN.test(email.trim())) next.email = t.emailInvalid;
|
|
633
|
+
if (!consent) next.consent = t.consentRequired;
|
|
634
|
+
setErrors(next);
|
|
635
|
+
if (Object.keys(next).length > 0 || !requestType) return;
|
|
636
|
+
setSubmitting(true);
|
|
637
|
+
try {
|
|
638
|
+
const captcha = await getCaptchaToken().catch(() => void 0);
|
|
639
|
+
if (!captcha) {
|
|
640
|
+
setFormError(t.captchaError);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const input = {
|
|
644
|
+
requestType,
|
|
645
|
+
name: name.trim(),
|
|
646
|
+
email: email.trim(),
|
|
647
|
+
...details.trim() ? { details: details.trim() } : {},
|
|
648
|
+
captcha
|
|
649
|
+
};
|
|
650
|
+
const result = await (submit ?? ((body) => postToRoute(endpoint, body)))(input).catch(
|
|
651
|
+
() => ({ ok: false, status: 0 })
|
|
652
|
+
);
|
|
653
|
+
if (!result.ok) {
|
|
654
|
+
setFormError(rejectionMessage(result.status, t));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
setAck(result.data);
|
|
658
|
+
onSubmitted?.(result.data);
|
|
659
|
+
} finally {
|
|
660
|
+
setSubmitting(false);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (ack) {
|
|
664
|
+
const deadline = new Intl.DateTimeFormat(locale, {
|
|
665
|
+
dateStyle: "long",
|
|
666
|
+
timeStyle: "short"
|
|
667
|
+
}).format(new Date(ack.slaDueAt));
|
|
668
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { role: "status", "data-slot": "data-request-form-success", className: (0, import_utils5.cn)("rounded-lg border border-border p-6 text-sm", className), children: t.success(deadline) });
|
|
669
|
+
}
|
|
670
|
+
const errorId = (field) => errors[field] ? `${id}-${field}-error` : void 0;
|
|
671
|
+
const fieldError = (field) => errors[field] ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { id: `${id}-${field}-error`, className: "text-xs text-destructive", children: errors[field] }) : null;
|
|
672
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
|
|
673
|
+
"form",
|
|
674
|
+
{
|
|
675
|
+
noValidate: true,
|
|
676
|
+
onSubmit: handleSubmit,
|
|
677
|
+
onFocusCapture: onInteract,
|
|
678
|
+
onPointerDown: onInteract,
|
|
679
|
+
"data-slot": "data-request-form",
|
|
680
|
+
className: (0, import_utils5.cn)("space-y-4", className),
|
|
681
|
+
children: [
|
|
682
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
|
|
683
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-type`, className: "text-sm font-medium", children: t.requestTypeLabel }),
|
|
684
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
|
|
685
|
+
"select",
|
|
686
|
+
{
|
|
687
|
+
id: `${id}-type`,
|
|
688
|
+
value: requestType,
|
|
689
|
+
onChange: (e) => setRequestType(e.target.value),
|
|
690
|
+
"aria-invalid": errors.requestType ? true : void 0,
|
|
691
|
+
"aria-describedby": errorId("requestType"),
|
|
692
|
+
className: (0, import_utils5.cn)(FIELD_CLASS, errors.requestType ? "border-destructive" : "border-border"),
|
|
693
|
+
children: [
|
|
694
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("option", { value: "", children: t.requestTypePlaceholder }),
|
|
695
|
+
requestTypes.map((type) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("option", { value: type, children: t.requestTypes[type] }, type))
|
|
696
|
+
]
|
|
697
|
+
}
|
|
698
|
+
),
|
|
699
|
+
fieldError("requestType")
|
|
700
|
+
] }),
|
|
701
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
|
|
702
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-name`, className: "text-sm font-medium", children: t.nameLabel }),
|
|
703
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
|
|
704
|
+
"input",
|
|
705
|
+
{
|
|
706
|
+
id: `${id}-name`,
|
|
707
|
+
type: "text",
|
|
708
|
+
autoComplete: "name",
|
|
709
|
+
value: name,
|
|
710
|
+
onChange: (e) => setName(e.target.value),
|
|
711
|
+
"aria-invalid": errors.name ? true : void 0,
|
|
712
|
+
"aria-describedby": errorId("name"),
|
|
713
|
+
className: (0, import_utils5.cn)(FIELD_CLASS, errors.name ? "border-destructive" : "border-border")
|
|
714
|
+
}
|
|
715
|
+
),
|
|
716
|
+
fieldError("name")
|
|
717
|
+
] }),
|
|
718
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
|
|
719
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-email`, className: "text-sm font-medium", children: t.emailLabel }),
|
|
720
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
|
|
721
|
+
"input",
|
|
722
|
+
{
|
|
723
|
+
id: `${id}-email`,
|
|
724
|
+
type: "email",
|
|
725
|
+
autoComplete: "email",
|
|
726
|
+
value: email,
|
|
727
|
+
onChange: (e) => setEmail(e.target.value),
|
|
728
|
+
"aria-invalid": errors.email ? true : void 0,
|
|
729
|
+
"aria-describedby": errorId("email"),
|
|
730
|
+
className: (0, import_utils5.cn)(FIELD_CLASS, errors.email ? "border-destructive" : "border-border")
|
|
731
|
+
}
|
|
732
|
+
),
|
|
733
|
+
fieldError("email")
|
|
734
|
+
] }),
|
|
735
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
|
|
736
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-details`, className: "text-sm font-medium", children: t.detailsLabel }),
|
|
737
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
|
|
738
|
+
"textarea",
|
|
739
|
+
{
|
|
740
|
+
id: `${id}-details`,
|
|
741
|
+
rows: 4,
|
|
742
|
+
value: details,
|
|
743
|
+
onChange: (e) => setDetails(e.target.value),
|
|
744
|
+
placeholder: t.detailsPlaceholder,
|
|
745
|
+
className: (0, import_utils5.cn)(FIELD_CLASS, "border-border")
|
|
746
|
+
}
|
|
747
|
+
)
|
|
748
|
+
] }),
|
|
749
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
|
|
750
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "flex items-start gap-2", children: [
|
|
751
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
|
|
752
|
+
"input",
|
|
753
|
+
{
|
|
754
|
+
id: `${id}-consent`,
|
|
755
|
+
type: "checkbox",
|
|
756
|
+
checked: consent,
|
|
757
|
+
onChange: (e) => setConsent(e.target.checked),
|
|
758
|
+
"aria-invalid": errors.consent ? true : void 0,
|
|
759
|
+
"aria-describedby": errorId("consent"),
|
|
760
|
+
className: "mt-1"
|
|
761
|
+
}
|
|
762
|
+
),
|
|
763
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-consent`, className: "text-sm text-foreground/80", children: t.consent })
|
|
764
|
+
] }),
|
|
765
|
+
fieldError("consent")
|
|
766
|
+
] }),
|
|
767
|
+
formError && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { role: "alert", className: "text-sm text-destructive", children: formError }),
|
|
768
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
|
|
769
|
+
"button",
|
|
770
|
+
{
|
|
771
|
+
type: "submit",
|
|
772
|
+
disabled: submitting,
|
|
773
|
+
className: "rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-60",
|
|
774
|
+
children: submitting ? t.submitting : t.submit
|
|
775
|
+
}
|
|
776
|
+
)
|
|
777
|
+
]
|
|
778
|
+
}
|
|
779
|
+
);
|
|
780
|
+
}
|
|
525
781
|
// Annotate the CommonJS export names for ESM import in node:
|
|
526
782
|
0 && (module.exports = {
|
|
527
783
|
AddToCartButton,
|
|
528
784
|
CartIcon,
|
|
529
785
|
Collection,
|
|
786
|
+
DATA_REQUEST_TYPES,
|
|
787
|
+
DEFAULT_DATA_REQUEST_FORM_LABELS,
|
|
788
|
+
DataRequestForm,
|
|
530
789
|
ShareButtons,
|
|
531
790
|
useCollection
|
|
532
791
|
});
|