@reblu/site-blocks 0.20.0 → 0.22.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 +35 -0
- package/README.md +209 -14
- package/dist/client.cjs +218 -83
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +40 -13
- package/dist/client.d.ts +40 -13
- package/dist/client.js +221 -86
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +620 -321
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +144 -2
- package/dist/index.d.ts +144 -2
- package/dist/index.js +614 -321
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# @reblu/site-blocks
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 01d0e15: Academy catalogue filters by price and instructor (REB-547). `@reblu/site-contracts` exports `AcademyCourseListQuery`, the documented query of `GET /api/site/academy/courses`. `@reblu/site-client` adds `academy.instructors()` (instructors of published courses, for the filter), and `AcademyCourseListParams` is now that query type, so the SDK cannot drift from the API. `@reblu/site-blocks` adds `CourseCatalogFilters`, a drop-in instructor and price filter (bands in integer cents plus «Gratis»), RSC-safe like `ShopFilters`.
|
|
8
|
+
- d1db04e: Tenant-configurable interest question on the contact form (REB-759).
|
|
9
|
+
- `Config.contactInterest`: `{ label, options }` or `null`, the tenant's own question and options from nexus. `null` means the field must not render; there is no default catalogue.
|
|
10
|
+
- `ContactFormInput.interests`: the chosen option labels (up to 20). The server keeps only labels the tenant offers.
|
|
11
|
+
- New drop-in `ContactInterestField` in `@reblu/site-blocks`: a controlled checkbox group that renders nothing when the tenant has no question. Slots: `contact-interest`, `contact-interest-option`.
|
|
12
|
+
|
|
13
|
+
- 5ca48ee: Discount code usage limits are enforced before payment (REB-912).
|
|
14
|
+
- New `ShopErrorCode` `DISCOUNT_LIMIT_REACHED` (400): checkout refuses a discount code that has used up its total uses (slots held by open payment sessions count), before any payment session is created. The buyer can continue without the code.
|
|
15
|
+
- `DEFAULT_SHOP_ERROR_MESSAGES.DISCOUNT_LIMIT_REACHED`: «Este código ya ha alcanzado su límite de usos».
|
|
16
|
+
- A per-customer limit still answers `DISCOUNT_INVALID` with a neutral message.
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- Updated dependencies [01d0e15]
|
|
21
|
+
- Updated dependencies [d1db04e]
|
|
22
|
+
- Updated dependencies [5ca48ee]
|
|
23
|
+
- @reblu/site-contracts@1.22.0
|
|
24
|
+
|
|
25
|
+
## 0.21.0
|
|
26
|
+
|
|
27
|
+
### Minor Changes
|
|
28
|
+
|
|
29
|
+
- 76e64f3: Free-preview lessons for the public, and a redesigned course programme (REB-1135).
|
|
30
|
+
- New RSC-safe `LessonPreview`: the hosted video player in an `<iframe>` plus the lesson body through the sanitising Markdown pipeline, over `getLessonPreview()` from `@reblu/site-next/academy-server`. Spanish copy overridable via `DEFAULT_LESSON_PREVIEW_LABELS`. The SITE's CSP needs `frame-src https://player.mux.com`.
|
|
31
|
+
- `CourseSyllabus` redesign: rotating chevron icon, a video/reading icon per lesson, lesson durations as «m:ss» / «h:mm:ss» aligned right, the course total in the summary, «Expandir todo / Contraer todo», and «Vista previa gratuita» as a call to action through the new `getPreviewHref`, `previewHrefTemplate` or `onPreview` props. Every existing `data-slot` is kept; new slots: `course-syllabus-header`, `course-syllabus-toggle-all`, `course-syllabus-chevron`, `course-syllabus-lesson-icon`, `course-syllabus-lesson-duration`, `course-syllabus-preview`. New labels: `expandAll`, `collapseAll`, `videoLesson`, `textLesson`. Visible change: lesson durations read «10:00» instead of «10 min».
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- Updated dependencies [76e64f3]
|
|
36
|
+
- @reblu/site-contracts@1.21.0
|
|
37
|
+
|
|
3
38
|
## 0.20.0
|
|
4
39
|
|
|
5
40
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -25,9 +25,11 @@ colour on the red background. See `docs/theming/theming.md`.
|
|
|
25
25
|
| `sanitizeHtml` | `.` | Sanitises an HTML string before `dangerouslySetInnerHTML` — the **HTML boundary**. See "Security boundaries" below. |
|
|
26
26
|
| `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
|
|
27
27
|
| `CourseDescription` | `.` | Drop-in for the rich-text course `description` (`client.academy.bySlug()`). Server Component. See "Rich-text descriptions". |
|
|
28
|
+
| `LessonPreview`, `DEFAULT_LESSON_PREVIEW_LABELS` | `.` | A public free-preview lesson: hosted video player + sanitised body (`getLessonPreview()`). Server Component. See "Free-preview lesson". |
|
|
28
29
|
| `Treatment`, `ParentPage`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. `Specialty`: deprecated alias, see below. |
|
|
29
30
|
| `ProfessionalFields` | `.` | The tenant's own professional fields (`professional.fields`) on a team card or profile. RSC-safe. See below. |
|
|
30
31
|
| `ShopFilters` | `.` | Plug-and-play shop filter chrome over `GET /api/site/shop/facets`. RSC-safe. See "Shop filters" below. |
|
|
32
|
+
| `CourseCatalogFilters`, `DEFAULT_COURSE_PRICE_BANDS` | `.` | Course catalogue filters by instructor and price (`client.academy.instructors()`). RSC-safe. See "Course catalogue filters". |
|
|
31
33
|
| `CartLineList`, `CartSummary`, `CartAdjustmentsNotice`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
|
|
32
34
|
| `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
|
|
33
35
|
| `ProductTrustItems`, `DEFAULT_TRUST_ICONS` | `.` | Store-wide trust messages from `ShopConfig.productTrustItems`. Server Component. See "Product trust items" below. |
|
|
@@ -633,6 +635,27 @@ NIF/NIE or ID document, name and billing address), `INVOICING_NOT_READY`
|
|
|
633
635
|
(503) and `FULL_INVOICE_UNAVAILABLE` (422 — `wantsFullInvoice: true` sent to a
|
|
634
636
|
shop that issues no invoices).
|
|
635
637
|
|
|
638
|
+
A discount code that has used up its total uses is refused by checkout
|
|
639
|
+
BEFORE any payment session exists, with `DISCOUNT_LIMIT_REACHED` (400, REB-912)
|
|
640
|
+
and the default copy «Este código ya ha alcanzado su límite de usos». Let the
|
|
641
|
+
buyer remove the code and continue; nothing was charged. Slots held by open
|
|
642
|
+
payment sessions count, so the last use can never be sold twice. A
|
|
643
|
+
per-customer limit keeps answering `DISCOUNT_INVALID` with a neutral message,
|
|
644
|
+
so the response never reveals whether an email already bought.
|
|
645
|
+
|
|
646
|
+
```tsx
|
|
647
|
+
import { getShopErrorMessage } from '@reblu/site-blocks'
|
|
648
|
+
import { ApiError } from '@reblu/site-client'
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
await client.shop.checkout(body)
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (error instanceof ApiError && error.body?.code === 'DISCOUNT_LIMIT_REACHED') {
|
|
654
|
+
setDiscountError(getShopErrorMessage('DISCOUNT_LIMIT_REACHED')) // offer «continuar sin código»
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
```
|
|
658
|
+
|
|
636
659
|
## Product trust items
|
|
637
660
|
|
|
638
661
|
`ProductTrustItems` renders the store-wide trust messages (shipping, returns,
|
|
@@ -906,40 +929,181 @@ It renders the package's `ProductCard` by default. To use your own card, pass
|
|
|
906
929
|
`headingClassName` replaces the heading's typography (see the className rule
|
|
907
930
|
above). It renders nothing for an empty list.
|
|
908
931
|
|
|
909
|
-
## Course programme — `CourseSyllabus` (REB-1134)
|
|
932
|
+
## Course programme — `CourseSyllabus` (REB-1134, redesigned in REB-1135)
|
|
910
933
|
|
|
911
934
|
The programme of a public course page: every module, and inside each module the
|
|
912
935
|
lessons that make it up. The header reads «Programa» and «3 módulos · 12
|
|
913
|
-
lecciones
|
|
914
|
-
|
|
915
|
-
module
|
|
916
|
-
|
|
936
|
+
lecciones · 2 h 40 min», with an «Expandir todo / Contraer todo» control. Each
|
|
937
|
+
module row shows a number badge, its lesson count and, when every lesson in it
|
|
938
|
+
has a duration, the module's total («4 lecciones · 1 h 20 min»), and a chevron
|
|
939
|
+
that rotates when open. Opening a module lists its lessons in order: a video or
|
|
940
|
+
reading icon, the title, the duration right-aligned as «m:ss» / «h:mm:ss», and on
|
|
941
|
+
free-preview lessons «Vista previa gratuita» as a call to action.
|
|
917
942
|
|
|
918
943
|
```tsx
|
|
919
944
|
import { CourseSyllabus } from '@reblu/site-blocks/client'
|
|
920
945
|
|
|
921
946
|
// `course` is an `AcademyCourseDetail` from `client.academy.bySlug(slug)`.
|
|
922
|
-
//
|
|
923
|
-
<CourseSyllabus
|
|
947
|
+
// From a Server Component page, use the serialisable template:
|
|
948
|
+
<CourseSyllabus
|
|
949
|
+
modules={course.modules}
|
|
950
|
+
previewHrefTemplate={`/formacion/${course.slug}/preview/{lessonSlug}`}
|
|
951
|
+
/>
|
|
952
|
+
|
|
953
|
+
// From a Client Component you can pass functions instead:
|
|
954
|
+
<CourseSyllabus
|
|
955
|
+
modules={course.modules}
|
|
956
|
+
getPreviewHref={(lesson) => `/formacion/${course.slug}/preview/${lesson.slug}`}
|
|
957
|
+
// or: onPreview={(lesson) => openPreviewModal(lesson.slug)}
|
|
958
|
+
/>
|
|
924
959
|
```
|
|
925
960
|
|
|
961
|
+
- **Free preview call to action.** `getPreviewHref(lesson)` (or the
|
|
962
|
+
serialisable `previewHrefTemplate`, with `{lessonSlug}` and `{lessonId}`)
|
|
963
|
+
renders a link; `onPreview(lesson)` renders a button (e.g. to open a modal).
|
|
964
|
+
Both apply only to lessons with `isFreePreview`. With neither, the flag is a
|
|
965
|
+
plain label. The link/button is named «Vista previa gratuita: {título}». Point
|
|
966
|
+
it at a page that renders `LessonPreview` (see "Free-preview lesson").
|
|
926
967
|
- **Public data only.** It renders what the API serves. The API returns
|
|
927
968
|
published lessons only, and never a video, playback or attachment id, a
|
|
928
969
|
signed URL or the lesson content.
|
|
929
970
|
- **Accessibility.** The block is a `region` named by its heading. A module
|
|
930
971
|
with lessons is a `<button>` with `aria-expanded` and `aria-controls`
|
|
931
|
-
pointing at its lesson list. Tab reaches
|
|
932
|
-
|
|
972
|
+
pointing at its lesson list. Tab reaches «Expandir todo», then each module;
|
|
973
|
+
Enter and Space open and close it, and an open module's preview links follow
|
|
974
|
+
it in the tab order. Modules open independently and start closed. Icons are
|
|
975
|
+
`aria-hidden`; each lesson's type is announced as «Vídeo» / «Lectura».
|
|
976
|
+
Durations are `<time datetime="PT10M0S">`.
|
|
933
977
|
- **Modules without lessons** render as a plain row with the title, not a
|
|
934
978
|
button. A payload without `lessons` renders the same way.
|
|
935
979
|
- **Copy.** The defaults are Spanish (`DEFAULT_COURSE_SYLLABUS_LABELS`).
|
|
936
980
|
Override any of them with `labels={{ heading, moduleSingular, modulePlural,
|
|
937
|
-
lessonSingular, lessonPlural, freePreview, hours, minutes
|
|
938
|
-
|
|
981
|
+
lessonSingular, lessonPlural, freePreview, hours, minutes, expandAll,
|
|
982
|
+
collapseAll, videoLesson, textLesson }}`.
|
|
983
|
+
- **Styling.** Uses only the SDK tokens (`border`, `card`, `muted`, `primary`,
|
|
984
|
+
`ring`). `headingLevel` (default `2`), `headingClassName` (replaces the
|
|
939
985
|
heading's typography, see the className rule above) and `className`. Style
|
|
940
|
-
the parts with `[data-slot="course-syllabus"]`, `course-syllabus-
|
|
941
|
-
`course-syllabus-
|
|
942
|
-
|
|
986
|
+
the parts with `[data-slot="course-syllabus"]`, `course-syllabus-header`,
|
|
987
|
+
`course-syllabus-summary`, `course-syllabus-toggle-all`,
|
|
988
|
+
`course-syllabus-module`, `course-syllabus-chevron`, `course-syllabus-lessons`,
|
|
989
|
+
`course-syllabus-lesson`, `course-syllabus-lesson-icon` (`data-type="video" |
|
|
990
|
+
"text"`), `course-syllabus-lesson-title`, `course-syllabus-lesson-duration`
|
|
991
|
+
and `course-syllabus-preview`. No slot was renamed in REB-1135. It renders
|
|
992
|
+
nothing for a course without modules.
|
|
993
|
+
|
|
994
|
+
## Course catalogue filters — `CourseCatalogFilters` (REB-547)
|
|
995
|
+
|
|
996
|
+
Drop-in filters for the academy catalogue: an instructor filter fed by
|
|
997
|
+
`client.academy.instructors()` (instructors of published courses, sorted by
|
|
998
|
+
name) and a price filter (bands in integer cents plus «Gratis»). Each option
|
|
999
|
+
maps 1:1 onto a `client.academy.courses()` param: `instructor`, `priceMin`,
|
|
1000
|
+
`priceMax`, `free`. Same RSC-safe contract as `ShopFilters`: every option is a
|
|
1001
|
+
real `<a href>`; pass `onChange` only from a `'use client'` boundary.
|
|
1002
|
+
|
|
1003
|
+
```tsx
|
|
1004
|
+
import { CourseCatalogFilters } from '@reblu/site-blocks'
|
|
1005
|
+
import type { AcademyCourseListQuery } from '@reblu/site-contracts'
|
|
1006
|
+
import { client } from '@/lib/reblu'
|
|
1007
|
+
|
|
1008
|
+
export default async function CoursesPage({
|
|
1009
|
+
searchParams,
|
|
1010
|
+
}: {
|
|
1011
|
+
searchParams: Promise<Record<string, string | undefined>>
|
|
1012
|
+
}) {
|
|
1013
|
+
const sp = await searchParams
|
|
1014
|
+
const num = (v?: string) => (v ? Number(v) : undefined)
|
|
1015
|
+
const active: AcademyCourseListQuery = {
|
|
1016
|
+
instructor: sp.instructor,
|
|
1017
|
+
priceMin: num(sp.priceMin),
|
|
1018
|
+
priceMax: num(sp.priceMax),
|
|
1019
|
+
free: sp.free === 'true',
|
|
1020
|
+
}
|
|
1021
|
+
const [courses, instructors] = await Promise.all([
|
|
1022
|
+
client.academy.courses(active),
|
|
1023
|
+
client.academy.instructors(),
|
|
1024
|
+
])
|
|
1025
|
+
|
|
1026
|
+
// Keep the other params; `free` and a price band exclude each other.
|
|
1027
|
+
const href = (patch: Partial<AcademyCourseListQuery>) => {
|
|
1028
|
+
const next = new URLSearchParams()
|
|
1029
|
+
for (const [k, v] of Object.entries({ ...active, ...patch })) {
|
|
1030
|
+
if (v !== undefined && v !== false) next.set(k, String(v))
|
|
1031
|
+
}
|
|
1032
|
+
const qs = next.toString()
|
|
1033
|
+
return qs ? `/cursos?${qs}` : '/cursos'
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return (
|
|
1037
|
+
<>
|
|
1038
|
+
<CourseCatalogFilters
|
|
1039
|
+
instructors={instructors.data}
|
|
1040
|
+
active={active}
|
|
1041
|
+
hrefs={{
|
|
1042
|
+
instructor: (id) => href({ instructor: id }),
|
|
1043
|
+
price: (min, max) => href({ priceMin: min, priceMax: max, free: false }),
|
|
1044
|
+
free: (next) => href({ free: next, priceMin: undefined, priceMax: undefined }),
|
|
1045
|
+
clearAll: () => '/cursos',
|
|
1046
|
+
}}
|
|
1047
|
+
/>
|
|
1048
|
+
{/* render courses.data */}
|
|
1049
|
+
</>
|
|
1050
|
+
)
|
|
1051
|
+
}
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
- **Price is integer cents.** The default bands (`DEFAULT_COURSE_PRICE_BANDS`)
|
|
1055
|
+
are EUR: up to 50, 50 to 150, 150 to 300, from 300. Pass `priceBands` for
|
|
1056
|
+
another catalogue and `currencyCode` / `locale` for the labels.
|
|
1057
|
+
- **«Gratis» wins.** The API applies `free=true` over any price range, so no
|
|
1058
|
+
band shows as active while it is on; the component's `onChange` patches turn
|
|
1059
|
+
one off when the other is picked, and `hrefs` should do the same.
|
|
1060
|
+
- **Unnamed instructors** (no first or last name) are not offered. An active
|
|
1061
|
+
instructor id that is no longer listed keeps the group visible, so it can be
|
|
1062
|
+
cleared.
|
|
1063
|
+
- Spanish default labels (`DEFAULT_COURSE_CATALOG_FILTERS_LABELS`), all
|
|
1064
|
+
overridable through `labels`.
|
|
1065
|
+
|
|
1066
|
+
## Free-preview lesson — `LessonPreview` (REB-1135)
|
|
1067
|
+
|
|
1068
|
+
A lesson the owner marked «Vista previa gratuita», for visitors who have not
|
|
1069
|
+
paid or enrolled: the video and the lesson body. Fetch it on the server with
|
|
1070
|
+
`getLessonPreview` (`@reblu/site-next/academy-server`), which returns `null`
|
|
1071
|
+
for any lesson the public may not see.
|
|
1072
|
+
|
|
1073
|
+
```tsx
|
|
1074
|
+
// app/formacion/[slug]/preview/[lessonSlug]/page.tsx — a Server Component
|
|
1075
|
+
import { notFound } from 'next/navigation'
|
|
1076
|
+
import { LessonPreview } from '@reblu/site-blocks'
|
|
1077
|
+
import { getLessonPreview } from '@reblu/site-next/academy-server'
|
|
1078
|
+
import { client } from '@/lib/reblu' // your server-side RebluClient
|
|
1079
|
+
|
|
1080
|
+
export const dynamic = 'force-dynamic' // the video token is short-lived: never ISR
|
|
1081
|
+
|
|
1082
|
+
export default async function Page({ params }) {
|
|
1083
|
+
const { slug, lessonSlug } = await params
|
|
1084
|
+
const lesson = await getLessonPreview(client, slug, lessonSlug)
|
|
1085
|
+
if (!lesson) notFound()
|
|
1086
|
+
return <LessonPreview lesson={lesson} headingLevel={1} />
|
|
1087
|
+
}
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
- **Player.** `video.embedUrl` is the provider's hosted player, already carrying
|
|
1091
|
+
its short-lived token, rendered in an `<iframe>` named «Vídeo: {título}» —
|
|
1092
|
+
no video library in the SDK. Only `https:` URLs are embedded. Add
|
|
1093
|
+
`frame-src https://player.mux.com` to the SITE's Content-Security-Policy.
|
|
1094
|
+
`video` is `null` for a text-only lesson (no player is rendered).
|
|
1095
|
+
- **Body.** `content` is Markdown, rendered through the same sanitising
|
|
1096
|
+
pipeline as `CourseDescription`: no raw HTML, no scripts, no `javascript:`
|
|
1097
|
+
links. Its headings sit one level below the lesson title. An empty body
|
|
1098
|
+
renders nothing.
|
|
1099
|
+
- **Semantics.** An `<article>` named by the title; the duration is a
|
|
1100
|
+
`<time>`. Server Component (RSC-safe), from the root entry.
|
|
1101
|
+
- **Copy.** Spanish defaults (`DEFAULT_LESSON_PREVIEW_LABELS`), override with
|
|
1102
|
+
`labels={{ badge, player, duration }}`.
|
|
1103
|
+
- **Styling.** `headingLevel` (default `2`), `headingId`, `headingClassName`,
|
|
1104
|
+
`contentClassName`, `className`; slots `lesson-preview`,
|
|
1105
|
+
`lesson-preview-badge`, `lesson-preview-duration`, `lesson-preview-player`,
|
|
1106
|
+
`lesson-preview-content`.
|
|
943
1107
|
|
|
944
1108
|
## Page blocks
|
|
945
1109
|
|
|
@@ -1101,6 +1265,37 @@ renderer (`headingOffset` demotes its headings under yours).
|
|
|
1101
1265
|
`markdownToPlainText` drops images and anything the sanitiser strips, and cuts
|
|
1102
1266
|
at a word boundary with `…` within `maxLength`.
|
|
1103
1267
|
|
|
1268
|
+
## Contact form interest question — `ContactInterestField` (REB-759)
|
|
1269
|
+
|
|
1270
|
+
The tenant's own optional question on the contact form, e.g. «¿Qué tratamiento
|
|
1271
|
+
te interesa?», with the options it configured in nexus (Integraciones →
|
|
1272
|
+
Formulario de Contacto). Pass `config.contactInterest` from `/api/site/config`
|
|
1273
|
+
and send the chosen labels as `interests`. Unconfigured (`null`, or an older
|
|
1274
|
+
base-api) renders nothing. **Never supply your own fallback list**: there is no
|
|
1275
|
+
default catalogue, and the server keeps only labels the tenant offers.
|
|
1276
|
+
|
|
1277
|
+
```tsx
|
|
1278
|
+
'use client'
|
|
1279
|
+
import { useState } from 'react'
|
|
1280
|
+
import { ContactInterestField } from '@reblu/site-blocks'
|
|
1281
|
+
import type { Config } from '@reblu/site-contracts'
|
|
1282
|
+
|
|
1283
|
+
export function ContactFormInterest({ config }: { config: Config }) {
|
|
1284
|
+
const [interests, setInterests] = useState<string[]>([])
|
|
1285
|
+
// ...on submit: client.contactForm.submit({ ...fields, interests })
|
|
1286
|
+
return (
|
|
1287
|
+
<ContactInterestField
|
|
1288
|
+
interest={config.contactInterest}
|
|
1289
|
+
value={interests}
|
|
1290
|
+
onChange={setInterests}
|
|
1291
|
+
/>
|
|
1292
|
+
)
|
|
1293
|
+
}
|
|
1294
|
+
```
|
|
1295
|
+
|
|
1296
|
+
Styling: `data-slot="contact-interest"` (the `<fieldset>`, its `<legend>` is the
|
|
1297
|
+
question) and `data-slot="contact-interest-option"` (each `<label>`).
|
|
1298
|
+
|
|
1104
1299
|
## GDPR rights form — `DataRequestForm` (REB-980)
|
|
1105
1300
|
|
|
1106
1301
|
Drop-in form for a SITE's public rights page (e.g. `/derechos-rgpd`): request
|