@reblu/site-blocks 0.18.0 → 0.20.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,38 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4ca8816: New `CourseSyllabus` on `@reblu/site-blocks/client`: the programme of a public course page over `AcademyCourseDetail.modules`. The header shows the number of modules and lessons. Each module with lessons is an expandable button (`aria-expanded`, `aria-controls`) showing its lesson count and total duration, and lists its published lessons in order with their duration and free-preview badge. Modules without lessons render as a plain row. Spanish default copy, overridable through `labels` (`DEFAULT_COURSE_SYLLABUS_LABELS`).
8
+ - 25d8189: Automatic staff discount (REB-1131). When the buyer has a server-validated staff session, base-api applies the active «Solo personal» code that gives the largest discount, with no code typed. Buyers without a staff session see no change.
9
+ - `@reblu/site-contracts`: adds the `ShopStaffAutoDiscount` type, `{ code, label, amount }`. It is an optional `autoDiscount` field on `ShopCartValidation`, `ShopDiscountApplication` and `ShopCheckoutSession`, and is absent for everyone else.
10
+ - `@reblu/site-next`: `createShopValidateCartRoute()` now forwards the `sb-*` auth cookies, the same as checkout and apply-discount. Opt out with `{ forwardAuthCookies: false }`. `validateCart()` returns `outcome.autoDiscount` when the server applied one.
11
+ - `@reblu/site-blocks`: `CartSummary` and `CheckoutForm` accept `autoDiscount` and show «Descuento de personal aplicado» with its amount. `CheckoutForm` shows no remove action for it, and the code field stays available. The text can be changed with `labels.staffDiscountApplied`. Without the prop, both components render exactly as before.
12
+
13
+ ### Patch Changes
14
+
15
+ - 6a0f8e6: `OrderConfirmation`: the default `foundDescription` now reads «Te hemos enviado un email con tu acceso.» Every shop buyer gets a customer account, and the order confirmation email carries a one-time link into it, where paid digital downloads live. The confirmation page never serves files. A SITE that passes its own `labels.foundDescription` is unaffected.
16
+ - Updated dependencies [25d8189]
17
+ - @reblu/site-contracts@1.20.0
18
+
19
+ ## 0.19.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 38d27c5: Add `ProfessionalFields`, a drop-in for the tenant-defined professional fields on a team profile (`professional.fields`, e.g. a registration number). `ProfileCard` gains an optional `fields` prop that shows the card fields. Additive: existing usage renders as before.
24
+ - 5ef5630: Add `ProductCarousel` on `@reblu/site-blocks/client`, a drop-in product strip for «Productos relacionados». It scrolls natively with scroll-snap, so touch users swipe it, and has previous/next arrows from `sm` up. The arrows are hidden when everything fits and disabled at the end you have reached. The track is a focusable, labelled region and arrow scrolling respects reduced motion. It renders `ProductCard` by default, or your own card through `renderProduct`, and needs no carousel dependency. Additive: no existing export changes.
25
+
26
+ ### Patch Changes
27
+
28
+ - 5ef5630: Every button the organisms render shows the pointer cursor on hover under Tailwind v4: `AddToCartButton`, the `CartLineList` quantity stepper and remove button, `CheckoutForm`, `CheckoutLoginPrompt`, `Collection` pagination and facet chips, `DataRequestForm`, `OrderConfirmation` and the `ShareButtons` copy button. Disabled buttons show `cursor-not-allowed`. A source guard (`test/button-cursor.test.ts`) fails on any new `<button>` without the pointer cursor.
29
+ - 49384d1: README: document the `MarkdownRenderer` `images` option (URL-prefix allowlist, custom `render`, `<figure>` captions) with a `next/image` example. No code change.
30
+ - Updated dependencies [95f5b5f]
31
+ - Updated dependencies [5ef5630]
32
+ - Updated dependencies [e27e1b0]
33
+ - @reblu/site-contracts@1.19.0
34
+ - @reblu/site-ui@0.7.2
35
+
3
36
  ## 0.18.0
4
37
 
5
38
  ### Minor Changes
package/README.md CHANGED
@@ -26,6 +26,7 @@ colour on the red background. See `docs/theming/theming.md`.
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
28
  | `Treatment`, `ParentPage`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. `Specialty`: deprecated alias, see below. |
29
+ | `ProfessionalFields` | `.` | The tenant's own professional fields (`professional.fields`) on a team card or profile. RSC-safe. See below. |
29
30
  | `ShopFilters` | `.` | Plug-and-play shop filter chrome over `GET /api/site/shop/facets`. RSC-safe. See "Shop filters" below. |
30
31
  | `CartLineList`, `CartSummary`, `CartAdjustmentsNotice`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
31
32
  | `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
@@ -37,6 +38,8 @@ colour on the red background. See `docs/theming/theming.md`.
37
38
  | `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
38
39
  | `AddToCartButton`, `CartIcon` | `./client` | Add-to-cart feedback (REB-837) — **moved here from `.`**, see below. |
39
40
  | `DataRequestForm` | `./client` | Drop-in public GDPR rights form over `POST /api/site/data-requests` (REB-980). See "GDPR rights form" below. |
41
+ | `ProductCarousel` | `./client` | Scroll-snap product strip with prev/next arrows («Productos relacionados»). See "Product carousel" below. |
42
+ | `CourseSyllabus`, `DEFAULT_COURSE_SYLLABUS_LABELS` | `./client` | A course's programme: expandable modules listing their lessons (`client.academy.bySlug()`). See "Course programme" below. |
40
43
 
41
44
  ## Boundary
42
45
 
@@ -171,6 +174,39 @@ Internal links (`/privacidad`, `#clause-4`) and `mailto:` links open in the same
171
174
  tab. External links (`https://…`, `//…`) open in a new tab and always carry
172
175
  `rel="noopener noreferrer"`.
173
176
 
177
+ ### Images: the `images` option
178
+
179
+ Without `images`, every image the sanitiser keeps renders as a lazy `<img>`
180
+ inside its paragraph. Pass `images` to restrict where they may load from and,
181
+ optionally, how they render:
182
+
183
+ ```tsx
184
+ import Image from 'next/image'
185
+
186
+ const MEDIA = 'https://<ref>.supabase.co/storage/v1/object/public/blog-media/'
187
+
188
+ <MarkdownRenderer
189
+ content={post.content}
190
+ headingOffset={1}
191
+ normalizeHeadings
192
+ images={{
193
+ // Absolute URL prefixes (ending in `/`); any other image is dropped.
194
+ allowedUrlPrefixes: [MEDIA],
195
+ // Optional: render an allowed image yourself. Default: a lazy <img>.
196
+ render: ({ src, alt }) => <Image src={src} alt={alt} width={1600} height={900} />,
197
+ }}
198
+ />
199
+ ```
200
+
201
+ - The prefix is compared on the parsed, normalised URL (`..` resolved, scheme
202
+ included), so `http://…` never matches an `https://…` prefix.
203
+ - A paragraph holding only an allowed image becomes a `<figure>`; the image
204
+ title (`![alt](url "caption")`) is its `<figcaption>`.
205
+ - `render` receives `{ src, alt }` only: give `next/image` a size you know
206
+ (e.g. encoded in the object name) or use `fill` inside a sized box, and allow
207
+ the host in `images.remotePatterns`.
208
+ - `SafeHtml` has no `images` option: its images keep the default rendering.
209
+
174
210
  ### Which authored attributes survive
175
211
 
176
212
  The renderer rebuilds each element to apply the tenant's design tokens and to
@@ -289,13 +325,31 @@ for the wiring steps and a full example app).
289
325
  | `AddedToCartToast` | `.` | `productName`, `cartHref`, `status?`, `quantity?`, `imageUrl?` | Yes |
290
326
  | `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?`, `lineMessage?` | Yes |
291
327
  | `CartAdjustmentsNotice` | `.` | `removed?`, `quantityChanges?`, `priceChanges?`, `labels?` | Yes |
292
- | `CartSummary` | `.` | `subtotal`, `discount?`, `shipping?`, `shippingEnabled?`, `total`, `notice?` | Yes |
328
+ | `CartSummary` | `.` | `subtotal`, `discount?`, `autoDiscount?`, `shipping?`, `shippingEnabled?`, `total`, `notice?` | Yes |
293
329
  | `CheckoutLoginPrompt` | `.` | `open`, `email`, `onEmailChange`, `onClose`, `onSubmitEmail`, `onContinueAsGuest` | Yes (renders `null` when `open` is false) |
294
- | `CheckoutForm` | `.` | `contact`, `onContactChange`, `discountCode`, `onDiscountCodeChange`, `onApplyDiscount`, `onRemoveDiscount`, `onSubmit` | Yes |
330
+ | `CheckoutForm` | `.` | `contact`, `onContactChange`, `discountCode`, `onDiscountCodeChange`, `onApplyDiscount`, `onRemoveDiscount`, `onSubmit`, `autoDiscount?` | Yes |
295
331
  | `OrderConfirmation` | `.` | `status`, `order`, `cartHref?`, `onRecheck?` | Yes |
296
332
  | `OrderInvoiceLink` | `.` | `invoice` (`order.invoice`), `labels?` | Yes (renders `null` without an invoice) |
297
333
  | `FullInvoiceFields` | `.` | `value`, `onChange`, `overCap`, `errors?`, `countries?`, `labels?` | Yes (controlled, no hooks) |
298
334
 
335
+ **Automatic staff discount (REB-1131).** For a signed-in staff member,
336
+ base-api applies the best «Solo personal» code by itself and reports it as
337
+ `autoDiscount` on validate-cart, apply-discount and checkout. Pass it to both
338
+ components and they show «Descuento de personal aplicado» with its amount
339
+ (override with `labels.staffDiscountApplied`). `CheckoutForm` offers no remove
340
+ action for it and keeps the code field: a typed code replaces it only when the
341
+ server finds it larger. Without `autoDiscount`, both render exactly as before.
342
+
343
+ ```tsx
344
+ const outcome = await validateCart() // @reblu/site-next/cart
345
+ // After an apply-discount call, prefer its `autoDiscount` (absent when the
346
+ // typed code won).
347
+ const autoDiscount = discountResponse?.autoDiscount ?? outcome.autoDiscount ?? null
348
+
349
+ <CartSummary subtotal={subtotal} autoDiscount={autoDiscount} total={subtotal - (autoDiscount?.amount ?? 0)} />
350
+ <CheckoutForm {...checkoutProps} autoDiscount={autoDiscount} />
351
+ ```
352
+
299
353
  **REB-837 breaking move**: `AddToCartButton` and `CartIcon` now carry real
300
354
  internal state (the confirmed/error timer, the bump-on-add guard) and ship
301
355
  from `@reblu/site-blocks/client` instead of the root `.` entry. Update the
@@ -764,6 +818,129 @@ export async function ServicesTeaser() {
764
818
  }
765
819
  ```
766
820
 
821
+ ## Professional fields — `ProfessionalFields` (REB-1099)
822
+
823
+ Each tenant defines its own fields for team profiles in nexus › Equipo ›
824
+ «Campos profesionales» — for example a registration number, a licence or a
825
+ membership id — and marks which are public and which also go on the card. The
826
+ SITE API serves the public ones as `professional.fields`
827
+ (`{ label, value, showOnCard }[]`): the list (`client.pages.list`) carries only
828
+ the card fields, the profile (`client.pages.bySlug`) every public one. The
829
+ labels are the tenant's; this package names no field.
830
+
831
+ On the team card, `ProfileCard` takes them directly:
832
+
833
+ ```tsx
834
+ import { ProfileCard } from '@reblu/site-blocks'
835
+
836
+ const { data: team } = await client.pages.list({ type: ['team'] })
837
+
838
+ {team.map((p) => (
839
+ <ProfileCard
840
+ key={p.slug}
841
+ name={p.title}
842
+ role={p.professional?.role ?? undefined}
843
+ imageUrl={p.coverImageUrl ?? undefined}
844
+ fields={p.professional?.fields}
845
+ href={`/equipo/${p.slug}`}
846
+ />
847
+ ))}
848
+ ```
849
+
850
+ On the profile page, drop in `ProfessionalFields` (a `<dl>`):
851
+
852
+ ```tsx
853
+ import { ProfessionalFields } from '@reblu/site-blocks'
854
+
855
+ const page = await client.pages.bySlug(slug)
856
+
857
+ <ProfessionalFields fields={page.professional?.fields} />
858
+ ```
859
+
860
+ `variant='card'` keeps only `showOnCard` fields. It renders nothing when there
861
+ is nothing to show, so it is safe to place unconditionally. Style it with
862
+ `className` or `[data-slot="professional-fields"]`.
863
+
864
+ ## Product carousel — `ProductCarousel` (REB-1120)
865
+
866
+ A horizontal product strip for «Productos relacionados» or «También te puede
867
+ interesar», with no carousel dependency. The track is a native scroll container
868
+ with CSS scroll-snap, so touch users swipe it. From `sm` up, previous/next
869
+ arrows scroll one visible page. The arrows are not rendered when every product
870
+ fits, and the arrow at the end you have reached is disabled.
871
+
872
+ ```tsx
873
+ 'use client'
874
+ import { ProductCarousel } from '@reblu/site-blocks/client'
875
+
876
+ // `related` is a `ShopProductListItem[]`, e.g. from `client.shop.products({ category })`
877
+ <ProductCarousel
878
+ products={related}
879
+ heading='También te puede interesar'
880
+ productHref={(p) => `/shop/${p.slug}`}
881
+ />
882
+ ```
883
+
884
+ It renders the package's `ProductCard` by default. To use your own card, pass
885
+ `renderProduct`. Any item with an `id` works:
886
+
887
+ ```tsx
888
+ <ProductCarousel
889
+ products={related}
890
+ heading='También te puede interesar'
891
+ renderProduct={(p) => <MyProductCard product={p} />}
892
+ />
893
+ ```
894
+
895
+ - **Accessibility.** The track is a focusable `region` with
896
+ `aria-roledescription="carrusel"`. It is named by `heading` when you pass one,
897
+ otherwise by `labels.region`. Once the track has focus, the keyboard arrow
898
+ keys scroll it. The arrows are `<button>`s with `aria-label`s and
899
+ `aria-controls` pointing at the track. Under `prefers-reduced-motion: reduce`
900
+ they jump instead of animating.
901
+ - **Copy.** The defaults are Spanish (`DEFAULT_PRODUCT_CAROUSEL_LABELS`).
902
+ Override them with `labels={{ region, roleDescription, previous, next }}`.
903
+ - **Layout.** About 1.5 cards are visible on mobile, so the next one peeks in,
904
+ then 3 from `sm` and 4 from `lg`. Change the width with `itemClassName`
905
+ (e.g. `basis-64`). Style it with `className` or `[data-slot="product-carousel"]`.
906
+ `headingClassName` replaces the heading's typography (see the className rule
907
+ above). It renders nothing for an empty list.
908
+
909
+ ## Course programme — `CourseSyllabus` (REB-1134)
910
+
911
+ The programme of a public course page: every module, and inside each module the
912
+ 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.
917
+
918
+ ```tsx
919
+ import { CourseSyllabus } from '@reblu/site-blocks/client'
920
+
921
+ // `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} />
924
+ ```
925
+
926
+ - **Public data only.** It renders what the API serves. The API returns
927
+ published lessons only, and never a video, playback or attachment id, a
928
+ signed URL or the lesson content.
929
+ - **Accessibility.** The block is a `region` named by its heading. A module
930
+ 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.
933
+ - **Modules without lessons** render as a plain row with the title, not a
934
+ button. A payload without `lessons` renders the same way.
935
+ - **Copy.** The defaults are Spanish (`DEFAULT_COURSE_SYLLABUS_LABELS`).
936
+ Override any of them with `labels={{ heading, moduleSingular, modulePlural,
937
+ lessonSingular, lessonPlural, freePreview, hours, minutes }}`.
938
+ - **Styling.** `headingLevel` (default `2`), `headingClassName` (replaces the
939
+ 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.
943
+
767
944
  ## Page blocks
768
945
 
769
946
  `PageBlocks` renders a page's registry blocks (`PageDetail.blocks` from
@@ -0,0 +1,4 @@
1
+ /** A heading level a caller can configure — `h1`…`h6`. */
2
+ type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
3
+
4
+ export type { HeadingLevel as H };
@@ -0,0 +1,4 @@
1
+ /** A heading level a caller can configure — `h1`…`h6`. */
2
+ type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
3
+
4
+ export type { HeadingLevel as H };