@reblu/site-blocks 0.14.0 → 0.16.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,34 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - af425de: REB-1005 / REB-1002 / REB-1003:
8
+ - `DATA_REQUEST_TYPES` now comes from `@reblu/site-contracts` (same value, still exported from `./client`, now also from the server-safe root entry). New `DATA_REQUEST_FIELD_LIMITS` export.
9
+ - `DataRequestForm`: focus moves to the first invalid field on a validation error and to the success status after a submission; the inputs carry `maxLength` at the server limits (name 200, email 320, details 5000).
10
+ - `TrustBadges`: optional `heading` + `headingId` render a `<section aria-labelledby>` landmark with the group headings one level below; new `sectionHeadingClassName`.
11
+ - `TrustBadges` renders a logo only when its URL is http(s) or root-relative — any other scheme shows the badge name instead.
12
+ - **BREAKING (visual):** `TrustBadges`' `headingClassName` now follows the REB-997 slot rule: it REPLACES the default `text-sm font-semibold uppercase tracking-widest` instead of merging with it. If you pass `headingClassName`, include the full typography you want.
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [af425de]
17
+ - @reblu/site-contracts@1.17.0
18
+
19
+ ## 0.15.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 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.
24
+ - 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.
25
+
26
+ ### Patch Changes
27
+
28
+ - 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.
29
+ - Updated dependencies [cc4e14e]
30
+ - @reblu/site-contracts@1.16.0
31
+
3
32
  ## 0.14.0
4
33
 
5
34
  ### Minor Changes
package/README.md CHANGED
@@ -30,11 +30,13 @@ colour on the red background. See `docs/theming/theming.md`.
30
30
  | `CartLineList`, `CartSummary`, `CartAdjustmentsNotice`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
31
31
  | `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
32
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. |
33
34
  | `buildFaqPageJsonLd`, `collectFaqItems` | `./seo` | schema.org `FAQPage` structured data. Directive-free (also on `.`). See "Page blocks" below. |
34
35
  | `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
35
36
  | `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
36
37
  | `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
37
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. |
38
40
 
39
41
  ## Boundary
40
42
 
@@ -42,6 +44,38 @@ colour on the red background. See `docs/theming/theming.md`.
42
44
  - **`@reblu/site-blocks`** = organisms + headless data hooks. Higher churn — its
43
45
  own package so a breaking change in an organism does not bump the stable atoms.
44
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`; `TrustBadges`'
59
+ `headingClassName` drops `text-sm font-semibold uppercase tracking-widest`
60
+ (and `sectionHeadingClassName` its `text-xl font-semibold`). Pass the full
61
+ typography you want for that slot.
62
+ - **Structural classes stay.** Layout an element needs (the trust row's
63
+ `flex items-center gap-3`, the `space-y-*` of a section or list, the
64
+ `max-w-none` of `MarkdownRenderer` / `SafeHtml`) is kept, and your layout
65
+ classes still override it through `cn` (`max-w-prose` beats `max-w-none`).
66
+ - **Your `text-*` classes reach the DOM verbatim** on those elements
67
+ (`CourseDescription`, `ProductTrustItems`, `TrustBadges`' headings,
68
+ `MarkdownRenderer`, `SafeHtml`):
69
+ they never go through tailwind-merge, so `text-h2 text-ink` renders as both.
70
+ - **Without the prop, nothing changes** — the defaults render exactly as before.
71
+
72
+ The root `className` of interactive or boxed drop-ins (`AddToCartButton`,
73
+ `CartIcon`, `CartAdjustmentsNotice`, cards, toasts, the login prompt) is still
74
+ merged with `cn` over their defaults: those defaults pair a text colour with
75
+ its background, and a root className is mostly used for spacing. Put a custom
76
+ font-size token there on an element of your own (or wrap the drop-in) rather
77
+ than on the root className.
78
+
45
79
  ## Authoring notes
46
80
 
47
81
  What `MarkdownRenderer` does with tenant-authored Markdown and HTML. These are
@@ -546,7 +580,9 @@ export default async function ProductPage() {
546
580
  A semantic `<ul>` (or `<ol>` via `as='ol'`) of `<li>` rows; each icon is
547
581
  `aria-hidden` because the text carries the meaning. Colours come from theme
548
582
  tokens only (`text-muted-foreground`, `text-foreground/60`). `className` styles
549
- the list, `itemClassName` each row.
583
+ the list, `itemClassName` each row — it **replaces** the row's default
584
+ `text-sm text-muted-foreground` and keeps its `flex items-center gap-3` layout
585
+ (REB-997).
550
586
 
551
587
  ### Overriding icons
552
588
 
@@ -566,6 +602,63 @@ import { Truck } from 'my-icon-set'
566
602
  `icons` cannot add a new icon key: an item whose `icon` this version does not
567
603
  know is still skipped.
568
604
 
605
+ ## Trust badges
606
+
607
+ `TrustBadges` renders the tenant's trust badges — partners, insurers, funding,
608
+ certifications, awards — managed in nexus (`/trust-badges`) and served by
609
+ `client.trustBadges.list()` (REB-996). The API only returns badges that are
610
+ switched on AND inside their optional validity window («Visible desde» /
611
+ «Visible hasta», whole days in the tenant's time zone), so toggling or expiring
612
+ a badge in nexus removes it from the SITE on the next revalidation, with no
613
+ deploy and no SITE change.
614
+
615
+ ```tsx
616
+ import { TrustBadges } from '@reblu/site-blocks'
617
+
618
+ export async function Footer() {
619
+ const { data: badges } = await client.trustBadges.list().catch(() => ({ data: [] }))
620
+ return (
621
+ <footer>
622
+ {/* … */}
623
+ <TrustBadges badges={badges} headingLevel={2} />
624
+ </footer>
625
+ )
626
+ }
627
+ ```
628
+
629
+ - Groups by type, each under a heading from `DEFAULT_TRUST_BADGE_HEADINGS`
630
+ (`SUBVENTION` → «Financiación», `CERTIFICATION` → «Certificaciones»,
631
+ `AWARD` → «Premios», `PARTNER` → «Colaboradores», `INSURANCE` →
632
+ «Aseguradoras»). Override any with `headings`; `null` hides one.
633
+ - `types` picks which groups render and in what order, e.g. only the funding
634
+ logos: `<TrustBadges badges={badges} types={['SUBVENTION']} />`. To fetch
635
+ only one type, `client.trustBadges.list({ type: 'SUBVENTION' })`.
636
+ - A logo is an `<img>` with `alt` = badge name, rendered only when its URL is
637
+ http(s) or root-relative (`/logos/x.svg`); any other scheme (`javascript:`,
638
+ `data:`, `//host`) is dropped and the badge shows its name as text, like a
639
+ badge without a logo. A badge `url` (http/https only) opens in a new window
640
+ with `rel="noopener noreferrer"` and a visually hidden «(se abre en una
641
+ ventana nueva)» — override it with `newWindowLabel`.
642
+ - `null`, `undefined` or an empty list render nothing — never an empty heading.
643
+ - **Section landmark.** Pass `heading` (e.g. «Nos avalan») and the drop-in
644
+ renders a `<section aria-labelledby={headingId}>` (default id
645
+ `trust-badges-heading`) with that heading at `headingLevel` and the group
646
+ headings one level below — so you keep the landmark without wrapping it:
647
+
648
+ ```tsx
649
+ <TrustBadges badges={badges} heading='Nos avalan' headingId='avales' headingLevel={2} />
650
+ ```
651
+
652
+ - Styling hooks: `className`, `groupClassName`, `headingClassName`,
653
+ `sectionHeadingClassName`, `listClassName`, `logoClassName` (e.g.
654
+ `brightness-0 invert` on a dark footer). **The REB-997 slot rule applies**:
655
+ `headingClassName` replaces the group headings' `text-sm font-semibold
656
+ uppercase tracking-widest` and `sectionHeadingClassName` replaces the section
657
+ heading's `text-xl font-semibold` — pass the full typography you want; custom
658
+ `text-*` tokens reach the DOM verbatim. `className`, `groupClassName`,
659
+ `listClassName` and `logoClassName` are layout/sizing slots and still merge
660
+ with `cn`.
661
+
569
662
  ## `ParentPage` (formerly `Specialty`)
570
663
 
571
664
  `ParentPage` renders a page that groups other pages: the hero and content
@@ -778,13 +871,87 @@ export default async function CoursePage({ params }: Props) {
778
871
  description's own headings under it (`headingLevel`, default `2`; override with
779
872
  `headingOffset`; `headingId` when the page renders it twice);
780
873
  - styling hooks: `className`, `headingClassName`, `contentClassName`, and
781
- `data-slot='course-description'` on the outer element.
874
+ `data-slot='course-description'` on the outer element. `headingClassName`
875
+ and `contentClassName` **replace** the default `text-xl font-semibold` /
876
+ `text-muted-foreground` (see [the className rule](#styling-a-drop-in--the-classname-rule-reb-997)).
782
877
 
783
878
  For any other rich-text Markdown field, `MarkdownRenderer` is the generic
784
879
  renderer (`headingOffset` demotes its headings under yours).
785
880
  `markdownToPlainText` drops images and anything the sanitiser strips, and cuts
786
881
  at a word boundary with `…` within `maxLength`.
787
882
 
883
+ ## GDPR rights form — `DataRequestForm` (REB-980)
884
+
885
+ Drop-in form for a SITE's public rights page (e.g. `/derechos-rgpd`): request
886
+ type, name, email, optional message and a consent checkbox. A submission lands
887
+ in the tenant's nexus queue (`/settings/data-requests`) with source
888
+ `PUBLIC_FORM` and its 72h clock running; the form then shows the response
889
+ deadline (`slaDueAt`) in a `role="status"` region.
890
+
891
+ ```tsx
892
+ 'use client'
893
+ import { useCallback, useState } from 'react'
894
+ import { DataRequestForm } from '@reblu/site-blocks/client'
895
+ import { RECAPTCHA_DATA_REQUEST_ACTION } from '@reblu/site-next/recaptcha'
896
+ import {
897
+ DeferredRecaptchaProvider,
898
+ useDeferredRecaptchaToken,
899
+ } from '@reblu/site-next/recaptcha-provider'
900
+
901
+ function Form({ onArm }: { onArm: () => void }) {
902
+ const getCaptchaToken = useDeferredRecaptchaToken(RECAPTCHA_DATA_REQUEST_ACTION, { onArm })
903
+ return <DataRequestForm getCaptchaToken={getCaptchaToken} onInteract={onArm} />
904
+ }
905
+
906
+ export function DataRequestSection({ siteKey }: { siteKey: string }) {
907
+ const [armed, setArmed] = useState(false)
908
+ const arm = useCallback(() => setArmed(true), [])
909
+ return (
910
+ <DeferredRecaptchaProvider siteKey={siteKey} armed={armed}>
911
+ <Form onArm={arm} />
912
+ </DeferredRecaptchaProvider>
913
+ )
914
+ }
915
+ ```
916
+
917
+ ```ts
918
+ // app/api/data-requests/route.ts — server side, the API key never reaches the browser
919
+ import { createDataRequestRoute } from '@reblu/site-next/route-handlers'
920
+
921
+ export const POST = createDataRequestRoute()
922
+ ```
923
+
924
+ `createDataRequestRoute` (REB-1005) validates the body, caps it at 16 KiB,
925
+ forwards the visitor IP for base-api's per-IP limit and keeps exactly the
926
+ statuses this form maps — see the `@reblu/site-next` README.
927
+
928
+ - **Render it on the reCAPTCHA key alone — not on the contact form.** The rights
929
+ form is a legal channel the tenant must keep open even when its contact form
930
+ is `DISABLED` or `NOT_CONFIGURED`; base-api's data-request endpoint ignores
931
+ `contactFormMode` and needs only a captcha. Gate on
932
+ `isSafeRecaptchaSiteKey(analytics.recaptchaSiteKey)` (`@reblu/site-next/recaptcha`),
933
+ never on `resolveContactFormAvailability`.
934
+ - **The list of rights** is `DATA_REQUEST_TYPES` — exported from
935
+ `@reblu/site-contracts` (server-safe, one source with base-api), and
936
+ re-exported from this package's root and `./client` entries.
937
+ - **Accessibility.** On a validation error focus moves to the first invalid
938
+ field; on success to the `role="status"` confirmation. The inputs carry
939
+ `maxLength` at the server limits (`DATA_REQUEST_FIELD_LIMITS`: name 200,
940
+ email 320, details 5000), so overlong text cannot be typed.
941
+
942
+ - **reCAPTCHA is required.** base-api rejects a request without a token, so the
943
+ form never submits when `getCaptchaToken` resolves `undefined`.
944
+ - **No server text reaches the visitor.** Rejections are mapped by status to
945
+ fixed copy: `400` → `validationError`, `422` → `captchaError`, `429` →
946
+ `rateLimitError`, anything else (or no answer) → `genericError`.
947
+ - **Transport.** By default the form POSTs JSON to `endpoint`
948
+ (`/api/data-requests`) and reads the `{ data }` envelope. Pass `submit` to
949
+ use your own transport; it returns `{ ok: true, data }` or `{ ok: false, status }`.
950
+ - **Copy** defaults to Spanish; override any string with `labels` (including
951
+ `requestTypes` per right and `success(deadline)`), the date locale with
952
+ `locale`, and the offered rights with `requestTypes`.
953
+ - `onSubmitted(ack)` fires after success — e.g. for analytics.
954
+
788
955
  ## Security boundaries
789
956
 
790
957
  Two sinks, two different helpers, in two different packages — do not reach for
package/dist/client.cjs CHANGED
@@ -34,6 +34,10 @@ __export(client_exports, {
34
34
  AddToCartButton: () => AddToCartButton,
35
35
  CartIcon: () => CartIcon,
36
36
  Collection: () => Collection,
37
+ DATA_REQUEST_FIELD_LIMITS: () => DATA_REQUEST_FIELD_LIMITS,
38
+ DATA_REQUEST_TYPES: () => import_site_contracts.DATA_REQUEST_TYPES,
39
+ DEFAULT_DATA_REQUEST_FORM_LABELS: () => DEFAULT_DATA_REQUEST_FORM_LABELS,
40
+ DataRequestForm: () => DataRequestForm,
37
41
  ShareButtons: () => ShareButtons,
38
42
  useCollection: () => useCollection
39
43
  });
@@ -522,11 +526,283 @@ function CartIcon({ itemCount, href, hydrated, bumpOnAdd, labels, className }) {
522
526
  }
523
527
  );
524
528
  }
529
+
530
+ // src/components/data-request-form.tsx
531
+ var import_react4 = require("react");
532
+ var import_utils5 = require("@reblu/site-ui/utils");
533
+ var import_site_contracts = require("@reblu/site-contracts");
534
+ var import_jsx_runtime5 = require("react/jsx-runtime");
535
+ var DATA_REQUEST_FIELD_LIMITS = Object.freeze({ name: 200, email: 320, details: 5e3 });
536
+ var DEFAULT_DATA_REQUEST_FORM_LABELS = {
537
+ requestTypeLabel: "Derecho que quieres ejercer",
538
+ requestTypePlaceholder: "Selecciona una opci\xF3n",
539
+ requestTypes: {
540
+ ACCESS: "Acceso",
541
+ RECTIFICATION: "Rectificaci\xF3n",
542
+ ERASURE: "Supresi\xF3n",
543
+ OBJECTION: "Oposici\xF3n",
544
+ PORTABILITY: "Portabilidad",
545
+ RESTRICTION: "Limitaci\xF3n del tratamiento",
546
+ AUTOMATED_DECISION: "No ser objeto de decisiones automatizadas",
547
+ INFORMATION: "Informaci\xF3n"
548
+ },
549
+ nameLabel: "Nombre y apellidos",
550
+ emailLabel: "Email",
551
+ detailsLabel: "Mensaje (opcional)",
552
+ detailsPlaceholder: "Describe tu solicitud",
553
+ consent: "Acepto que mis datos se traten para gestionar esta solicitud.",
554
+ submit: "Enviar solicitud",
555
+ submitting: "Enviando\u2026",
556
+ requestTypeRequired: "Elige el derecho que quieres ejercer.",
557
+ nameRequired: "Indica tu nombre.",
558
+ emailInvalid: "Introduce un email v\xE1lido.",
559
+ consentRequired: "Debes aceptar el tratamiento de tus datos para gestionar la solicitud.",
560
+ success: (deadline) => /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_jsx_runtime5.Fragment, { children: [
561
+ "Hemos recibido tu solicitud. Te responderemos como m\xE1ximo el ",
562
+ deadline,
563
+ "."
564
+ ] }),
565
+ captchaError: "No hemos podido verificar que no eres un robot. Recarga la p\xE1gina e int\xE9ntalo de nuevo.",
566
+ validationError: "Revisa los datos del formulario e int\xE9ntalo de nuevo.",
567
+ rateLimitError: "Has enviado demasiadas solicitudes. Espera unos minutos e int\xE9ntalo de nuevo.",
568
+ genericError: "No hemos podido enviar tu solicitud. Int\xE9ntalo de nuevo m\xE1s tarde."
569
+ };
570
+ var FIELD_ORDER = ["requestType", "name", "email", "consent"];
571
+ var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
572
+ async function postToRoute(endpoint, input) {
573
+ try {
574
+ const res = await fetch(endpoint, {
575
+ method: "POST",
576
+ headers: { "Content-Type": "application/json" },
577
+ body: JSON.stringify(input)
578
+ });
579
+ if (!res.ok) return { ok: false, status: res.status };
580
+ const json = await res.json();
581
+ return json.data ? { ok: true, data: json.data } : { ok: false, status: res.status };
582
+ } catch {
583
+ return { ok: false, status: 0 };
584
+ }
585
+ }
586
+ function rejectionMessage(status, t) {
587
+ if (status === 400) return t.validationError;
588
+ if (status === 422) return t.captchaError;
589
+ if (status === 429) return t.rateLimitError;
590
+ return t.genericError;
591
+ }
592
+ 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";
593
+ function DataRequestForm({
594
+ getCaptchaToken,
595
+ submit,
596
+ endpoint = "/api/data-requests",
597
+ onInteract,
598
+ onSubmitted,
599
+ requestTypes = import_site_contracts.DATA_REQUEST_TYPES,
600
+ labels,
601
+ locale = "es-ES",
602
+ className
603
+ }) {
604
+ const t = {
605
+ ...DEFAULT_DATA_REQUEST_FORM_LABELS,
606
+ ...labels,
607
+ requestTypes: { ...DEFAULT_DATA_REQUEST_FORM_LABELS.requestTypes, ...labels?.requestTypes }
608
+ };
609
+ const id = (0, import_react4.useId)();
610
+ const [requestType, setRequestType] = (0, import_react4.useState)("");
611
+ const [name, setName] = (0, import_react4.useState)("");
612
+ const [email, setEmail] = (0, import_react4.useState)("");
613
+ const [details, setDetails] = (0, import_react4.useState)("");
614
+ const [consent, setConsent] = (0, import_react4.useState)(false);
615
+ const [errors, setErrors] = (0, import_react4.useState)({});
616
+ const [formError, setFormError] = (0, import_react4.useState)(null);
617
+ const [submitting, setSubmitting] = (0, import_react4.useState)(false);
618
+ const [ack, setAck] = (0, import_react4.useState)(null);
619
+ const statusRef = (0, import_react4.useRef)(null);
620
+ (0, import_react4.useEffect)(() => {
621
+ if (ack) statusRef.current?.focus();
622
+ }, [ack]);
623
+ async function handleSubmit(event) {
624
+ event.preventDefault();
625
+ if (submitting) return;
626
+ setFormError(null);
627
+ const next = {};
628
+ if (!requestType) next.requestType = t.requestTypeRequired;
629
+ if (!name.trim()) next.name = t.nameRequired;
630
+ if (!EMAIL_PATTERN.test(email.trim())) next.email = t.emailInvalid;
631
+ if (!consent) next.consent = t.consentRequired;
632
+ setErrors(next);
633
+ const firstInvalid = FIELD_ORDER.find((field) => next[field]);
634
+ if (firstInvalid) {
635
+ document.getElementById(`${id}-${firstInvalid === "requestType" ? "type" : firstInvalid}`)?.focus();
636
+ return;
637
+ }
638
+ if (!requestType) return;
639
+ setSubmitting(true);
640
+ try {
641
+ const captcha = await getCaptchaToken().catch(() => void 0);
642
+ if (!captcha) {
643
+ setFormError(t.captchaError);
644
+ return;
645
+ }
646
+ const input = {
647
+ requestType,
648
+ name: name.trim(),
649
+ email: email.trim(),
650
+ ...details.trim() ? { details: details.trim() } : {},
651
+ captcha
652
+ };
653
+ const result = await (submit ?? ((body) => postToRoute(endpoint, body)))(input).catch(
654
+ () => ({ ok: false, status: 0 })
655
+ );
656
+ if (!result.ok) {
657
+ setFormError(rejectionMessage(result.status, t));
658
+ return;
659
+ }
660
+ setAck(result.data);
661
+ onSubmitted?.(result.data);
662
+ } finally {
663
+ setSubmitting(false);
664
+ }
665
+ }
666
+ if (ack) {
667
+ const deadline = new Intl.DateTimeFormat(locale, {
668
+ dateStyle: "long",
669
+ timeStyle: "short"
670
+ }).format(new Date(ack.slaDueAt));
671
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
672
+ "div",
673
+ {
674
+ ref: statusRef,
675
+ tabIndex: -1,
676
+ role: "status",
677
+ "data-slot": "data-request-form-success",
678
+ className: (0, import_utils5.cn)("rounded-lg border border-border p-6 text-sm", className),
679
+ children: t.success(deadline)
680
+ }
681
+ );
682
+ }
683
+ const errorId = (field) => errors[field] ? `${id}-${field}-error` : void 0;
684
+ 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;
685
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
686
+ "form",
687
+ {
688
+ noValidate: true,
689
+ onSubmit: handleSubmit,
690
+ onFocusCapture: onInteract,
691
+ onPointerDown: onInteract,
692
+ "data-slot": "data-request-form",
693
+ className: (0, import_utils5.cn)("space-y-4", className),
694
+ children: [
695
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
696
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-type`, className: "text-sm font-medium", children: t.requestTypeLabel }),
697
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
698
+ "select",
699
+ {
700
+ id: `${id}-type`,
701
+ value: requestType,
702
+ onChange: (e) => setRequestType(e.target.value),
703
+ "aria-invalid": errors.requestType ? true : void 0,
704
+ "aria-describedby": errorId("requestType"),
705
+ className: (0, import_utils5.cn)(FIELD_CLASS, errors.requestType ? "border-destructive" : "border-border"),
706
+ children: [
707
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("option", { value: "", children: t.requestTypePlaceholder }),
708
+ requestTypes.map((type) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("option", { value: type, children: t.requestTypes[type] }, type))
709
+ ]
710
+ }
711
+ ),
712
+ fieldError("requestType")
713
+ ] }),
714
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
715
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-name`, className: "text-sm font-medium", children: t.nameLabel }),
716
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
717
+ "input",
718
+ {
719
+ id: `${id}-name`,
720
+ type: "text",
721
+ autoComplete: "name",
722
+ maxLength: DATA_REQUEST_FIELD_LIMITS.name,
723
+ value: name,
724
+ onChange: (e) => setName(e.target.value),
725
+ "aria-invalid": errors.name ? true : void 0,
726
+ "aria-describedby": errorId("name"),
727
+ className: (0, import_utils5.cn)(FIELD_CLASS, errors.name ? "border-destructive" : "border-border")
728
+ }
729
+ ),
730
+ fieldError("name")
731
+ ] }),
732
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
733
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-email`, className: "text-sm font-medium", children: t.emailLabel }),
734
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
735
+ "input",
736
+ {
737
+ id: `${id}-email`,
738
+ type: "email",
739
+ autoComplete: "email",
740
+ maxLength: DATA_REQUEST_FIELD_LIMITS.email,
741
+ value: email,
742
+ onChange: (e) => setEmail(e.target.value),
743
+ "aria-invalid": errors.email ? true : void 0,
744
+ "aria-describedby": errorId("email"),
745
+ className: (0, import_utils5.cn)(FIELD_CLASS, errors.email ? "border-destructive" : "border-border")
746
+ }
747
+ ),
748
+ fieldError("email")
749
+ ] }),
750
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
751
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-details`, className: "text-sm font-medium", children: t.detailsLabel }),
752
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
753
+ "textarea",
754
+ {
755
+ id: `${id}-details`,
756
+ rows: 4,
757
+ maxLength: DATA_REQUEST_FIELD_LIMITS.details,
758
+ value: details,
759
+ onChange: (e) => setDetails(e.target.value),
760
+ placeholder: t.detailsPlaceholder,
761
+ className: (0, import_utils5.cn)(FIELD_CLASS, "border-border")
762
+ }
763
+ )
764
+ ] }),
765
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
766
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "flex items-start gap-2", children: [
767
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
768
+ "input",
769
+ {
770
+ id: `${id}-consent`,
771
+ type: "checkbox",
772
+ checked: consent,
773
+ onChange: (e) => setConsent(e.target.checked),
774
+ "aria-invalid": errors.consent ? true : void 0,
775
+ "aria-describedby": errorId("consent"),
776
+ className: "mt-1"
777
+ }
778
+ ),
779
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-consent`, className: "text-sm text-foreground/80", children: t.consent })
780
+ ] }),
781
+ fieldError("consent")
782
+ ] }),
783
+ formError && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { role: "alert", className: "text-sm text-destructive", children: formError }),
784
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
785
+ "button",
786
+ {
787
+ type: "submit",
788
+ disabled: submitting,
789
+ className: "rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-60",
790
+ children: submitting ? t.submitting : t.submit
791
+ }
792
+ )
793
+ ]
794
+ }
795
+ );
796
+ }
525
797
  // Annotate the CommonJS export names for ESM import in node:
526
798
  0 && (module.exports = {
527
799
  AddToCartButton,
528
800
  CartIcon,
529
801
  Collection,
802
+ DATA_REQUEST_FIELD_LIMITS,
803
+ DATA_REQUEST_TYPES,
804
+ DEFAULT_DATA_REQUEST_FORM_LABELS,
805
+ DataRequestForm,
530
806
  ShareButtons,
531
807
  useCollection
532
808
  });