@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 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». Each module row shows its lesson count and, when every lesson in it
914
- has a duration, the module's total («4 lecciones · 1 h 20 min»). Opening a
915
- module lists its lessons in order, with each lesson's duration and a «Vista
916
- previa gratuita» badge on free-preview lessons.
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
- // It works from a Server Component page: every prop is serialisable.
923
- <CourseSyllabus modules={course.modules} />
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 it; Enter and Space open and close
932
- it. Modules open independently and start closed.
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
- - **Styling.** `headingLevel` (default `2`), `headingClassName` (replaces the
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-module`,
941
- `course-syllabus-lessons` and `course-syllabus-lesson`. It renders nothing
942
- for a course without modules.
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