@reblu/site-blocks 0.21.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,27 @@
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
+
3
25
  ## 0.21.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -29,6 +29,7 @@ colour on the red background. See `docs/theming/theming.md`.
29
29
  | `Treatment`, `ParentPage`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. `Specialty`: deprecated alias, see below. |
30
30
  | `ProfessionalFields` | `.` | The tenant's own professional fields (`professional.fields`) on a team card or profile. RSC-safe. See below. |
31
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". |
32
33
  | `CartLineList`, `CartSummary`, `CartAdjustmentsNotice`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
33
34
  | `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
34
35
  | `ProductTrustItems`, `DEFAULT_TRUST_ICONS` | `.` | Store-wide trust messages from `ShopConfig.productTrustItems`. Server Component. See "Product trust items" below. |
@@ -634,6 +635,27 @@ NIF/NIE or ID document, name and billing address), `INVOICING_NOT_READY`
634
635
  (503) and `FULL_INVOICE_UNAVAILABLE` (422 — `wantsFullInvoice: true` sent to a
635
636
  shop that issues no invoices).
636
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
+
637
659
  ## Product trust items
638
660
 
639
661
  `ProductTrustItems` renders the store-wide trust messages (shipping, returns,
@@ -969,6 +991,78 @@ import { CourseSyllabus } from '@reblu/site-blocks/client'
969
991
  and `course-syllabus-preview`. No slot was renamed in REB-1135. It renders
970
992
  nothing for a course without modules.
971
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
+
972
1066
  ## Free-preview lesson — `LessonPreview` (REB-1135)
973
1067
 
974
1068
  A lesson the owner marked «Vista previa gratuita», for visitors who have not
@@ -1171,6 +1265,37 @@ renderer (`headingOffset` demotes its headings under yours).
1171
1265
  `markdownToPlainText` drops images and anything the sanitiser strips, and cuts
1172
1266
  at a word boundary with `…` within `maxLength`.
1173
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
+
1174
1299
  ## GDPR rights form — `DataRequestForm` (REB-980)
1175
1300
 
1176
1301
  Drop-in form for a SITE's public rights page (e.g. `/derechos-rgpd`): request