@reblu/site-blocks 0.14.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 +13 -0
- package/README.md +137 -2
- 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 +386 -256
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -7
- package/dist/index.d.ts +66 -7
- package/dist/index.js +383 -256
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
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
|
+
|
|
3
16
|
## 0.14.0
|
|
4
17
|
|
|
5
18
|
### 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,35 @@ 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`. 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
|
+
|
|
45
76
|
## Authoring notes
|
|
46
77
|
|
|
47
78
|
What `MarkdownRenderer` does with tenant-authored Markdown and HTML. These are
|
|
@@ -546,7 +577,9 @@ export default async function ProductPage() {
|
|
|
546
577
|
A semantic `<ul>` (or `<ol>` via `as='ol'`) of `<li>` rows; each icon is
|
|
547
578
|
`aria-hidden` because the text carries the meaning. Colours come from theme
|
|
548
579
|
tokens only (`text-muted-foreground`, `text-foreground/60`). `className` styles
|
|
549
|
-
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).
|
|
550
583
|
|
|
551
584
|
### Overriding icons
|
|
552
585
|
|
|
@@ -566,6 +599,45 @@ import { Truck } from 'my-icon-set'
|
|
|
566
599
|
`icons` cannot add a new icon key: an item whose `icon` this version does not
|
|
567
600
|
know is still skipped.
|
|
568
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
|
+
|
|
569
641
|
## `ParentPage` (formerly `Specialty`)
|
|
570
642
|
|
|
571
643
|
`ParentPage` renders a page that groups other pages: the hero and content
|
|
@@ -778,13 +850,76 @@ export default async function CoursePage({ params }: Props) {
|
|
|
778
850
|
description's own headings under it (`headingLevel`, default `2`; override with
|
|
779
851
|
`headingOffset`; `headingId` when the page renders it twice);
|
|
780
852
|
- styling hooks: `className`, `headingClassName`, `contentClassName`, and
|
|
781
|
-
`data-slot='course-description'` on the outer element.
|
|
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)).
|
|
782
856
|
|
|
783
857
|
For any other rich-text Markdown field, `MarkdownRenderer` is the generic
|
|
784
858
|
renderer (`headingOffset` demotes its headings under yours).
|
|
785
859
|
`markdownToPlainText` drops images and anything the sanitiser strips, and cuts
|
|
786
860
|
at a word boundary with `…` within `maxLength`.
|
|
787
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
|
+
|
|
788
923
|
## Security boundaries
|
|
789
924
|
|
|
790
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
|
});
|