@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 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?` | Yes |
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
- `validate-cart`'s rejection path (out-of-stock, price-changed, empty cart)
356
- surfaces through `CheckoutForm`'s own error-display conventions: pass the
357
- first `ShopCartValidationError.message` from `client.shop.validateCart(...)`
358
- as a toast/banner in your page (the component itself has no cart-wide error
359
- slot — it only owns contact/discount field-level errors via `contactErrors`/
360
- `discountError`).
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
  });