@reblu/site-blocks 0.17.1 → 0.19.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,28 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+ - 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.
9
+
10
+ ### Patch Changes
11
+
12
+ - 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.
13
+ - 49384d1: README: document the `MarkdownRenderer` `images` option (URL-prefix allowlist, custom `render`, `<figure>` captions) with a `next/image` example. No code change.
14
+ - Updated dependencies [95f5b5f]
15
+ - Updated dependencies [5ef5630]
16
+ - Updated dependencies [e27e1b0]
17
+ - @reblu/site-contracts@1.19.0
18
+ - @reblu/site-ui@0.7.2
19
+
20
+ ## 0.18.0
21
+
22
+ ### Minor Changes
23
+
24
+ - 801983f: New opt-in `normalizeHeadings` prop on `MarkdownRenderer` and `SafeHtml`: the body's shallowest heading lands at `1 + headingOffset`, every other heading keeps its depth relative to it, and levels clamp at h6. Standard Markdown (`##` sections) then renders `<h2>` under the page `<h1>` just like the Reblu convention (`#` sections), which renders byte-identically. Use it where the body sits directly under the page `<h1>` (a blog post, a legal document); leave it off where the body sits under its own section heading (a product or course description). Default `false`: nothing changes until a SITE opts in.
25
+
3
26
  ## 0.17.1
4
27
 
5
28
  ### Patch 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,7 @@ 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. |
40
42
 
41
43
  ## Boundary
42
44
 
@@ -105,6 +107,34 @@ To force a visible break, use any of:
105
107
  | A hard break inside one paragraph | Two spaces at the end of the first line |
106
108
  | A hard break, visibly | A trailing `\` at the end of the first line |
107
109
 
110
+ ### Heading levels: `headingOffset` and `normalizeHeadings`
111
+
112
+ `headingOffset` shifts every heading by a fixed amount: with `headingOffset={1}`
113
+ under the page's own `<h1>`, `#` renders `<h2>` and `##` renders `<h3>`. That
114
+ assumes the Reblu convention — `#` is a section — and standard Markdown pasted
115
+ or imported from elsewhere (`##` sections) then renders `<h3>` with no `<h2>`.
116
+
117
+ `normalizeHeadings` lands the body's **shallowest** heading at
118
+ `1 + headingOffset` instead, keeps every other heading's depth relative to it,
119
+ and clamps at h6:
120
+
121
+ | Body | `headingOffset={1}` | `+ normalizeHeadings` |
122
+ | --------------------------- | ------------------- | --------------------- |
123
+ | `#` / `##` (convention) | h2 / h3 | h2 / h3 (identical) |
124
+ | `##` / `###` (standard) | h3 / h4 | h2 / h3 |
125
+ | no headings | — | — (identical) |
126
+
127
+ ```tsx
128
+ // A blog post or legal document directly under the page <h1>:
129
+ <MarkdownRenderer content={post.content} headingOffset={1} normalizeHeadings />
130
+ ```
131
+
132
+ It is **opt-in**, because the renderer cannot see what sits above the body.
133
+ Leave it off where the body sits under its own section heading — e.g. a product
134
+ description under «Sobre este producto» (`h2`), whose `##` subsections must stay
135
+ one level below that heading. `SafeHtml` takes the same prop; `CourseDescription`
136
+ does not use it (its `##` sits below its own heading by contract).
137
+
108
138
  ### `hidden` hides a block — it does not withhold it
109
139
 
110
140
  An element marked `hidden` is not rendered and is not announced to a screen
@@ -143,6 +173,39 @@ Internal links (`/privacidad`, `#clause-4`) and `mailto:` links open in the same
143
173
  tab. External links (`https://…`, `//…`) open in a new tab and always carry
144
174
  `rel="noopener noreferrer"`.
145
175
 
176
+ ### Images: the `images` option
177
+
178
+ Without `images`, every image the sanitiser keeps renders as a lazy `<img>`
179
+ inside its paragraph. Pass `images` to restrict where they may load from and,
180
+ optionally, how they render:
181
+
182
+ ```tsx
183
+ import Image from 'next/image'
184
+
185
+ const MEDIA = 'https://<ref>.supabase.co/storage/v1/object/public/blog-media/'
186
+
187
+ <MarkdownRenderer
188
+ content={post.content}
189
+ headingOffset={1}
190
+ normalizeHeadings
191
+ images={{
192
+ // Absolute URL prefixes (ending in `/`); any other image is dropped.
193
+ allowedUrlPrefixes: [MEDIA],
194
+ // Optional: render an allowed image yourself. Default: a lazy <img>.
195
+ render: ({ src, alt }) => <Image src={src} alt={alt} width={1600} height={900} />,
196
+ }}
197
+ />
198
+ ```
199
+
200
+ - The prefix is compared on the parsed, normalised URL (`..` resolved, scheme
201
+ included), so `http://…` never matches an `https://…` prefix.
202
+ - A paragraph holding only an allowed image becomes a `<figure>`; the image
203
+ title (`![alt](url "caption")`) is its `<figcaption>`.
204
+ - `render` receives `{ src, alt }` only: give `next/image` a size you know
205
+ (e.g. encoded in the object name) or use `fill` inside a sized box, and allow
206
+ the host in `images.remotePatterns`.
207
+ - `SafeHtml` has no `images` option: its images keep the default rendering.
208
+
146
209
  ### Which authored attributes survive
147
210
 
148
211
  The renderer rebuilds each element to apply the tenant's design tokens and to
@@ -736,6 +799,94 @@ export async function ServicesTeaser() {
736
799
  }
737
800
  ```
738
801
 
802
+ ## Professional fields — `ProfessionalFields` (REB-1099)
803
+
804
+ Each tenant defines its own fields for team profiles in nexus › Equipo ›
805
+ «Campos profesionales» — for example a registration number, a licence or a
806
+ membership id — and marks which are public and which also go on the card. The
807
+ SITE API serves the public ones as `professional.fields`
808
+ (`{ label, value, showOnCard }[]`): the list (`client.pages.list`) carries only
809
+ the card fields, the profile (`client.pages.bySlug`) every public one. The
810
+ labels are the tenant's; this package names no field.
811
+
812
+ On the team card, `ProfileCard` takes them directly:
813
+
814
+ ```tsx
815
+ import { ProfileCard } from '@reblu/site-blocks'
816
+
817
+ const { data: team } = await client.pages.list({ type: ['team'] })
818
+
819
+ {team.map((p) => (
820
+ <ProfileCard
821
+ key={p.slug}
822
+ name={p.title}
823
+ role={p.professional?.role ?? undefined}
824
+ imageUrl={p.coverImageUrl ?? undefined}
825
+ fields={p.professional?.fields}
826
+ href={`/equipo/${p.slug}`}
827
+ />
828
+ ))}
829
+ ```
830
+
831
+ On the profile page, drop in `ProfessionalFields` (a `<dl>`):
832
+
833
+ ```tsx
834
+ import { ProfessionalFields } from '@reblu/site-blocks'
835
+
836
+ const page = await client.pages.bySlug(slug)
837
+
838
+ <ProfessionalFields fields={page.professional?.fields} />
839
+ ```
840
+
841
+ `variant='card'` keeps only `showOnCard` fields. It renders nothing when there
842
+ is nothing to show, so it is safe to place unconditionally. Style it with
843
+ `className` or `[data-slot="professional-fields"]`.
844
+
845
+ ## Product carousel — `ProductCarousel` (REB-1120)
846
+
847
+ A horizontal product strip for «Productos relacionados» or «También te puede
848
+ interesar», with no carousel dependency. The track is a native scroll container
849
+ with CSS scroll-snap, so touch users swipe it. From `sm` up, previous/next
850
+ arrows scroll one visible page. The arrows are not rendered when every product
851
+ fits, and the arrow at the end you have reached is disabled.
852
+
853
+ ```tsx
854
+ 'use client'
855
+ import { ProductCarousel } from '@reblu/site-blocks/client'
856
+
857
+ // `related` is a `ShopProductListItem[]`, e.g. from `client.shop.products({ category })`
858
+ <ProductCarousel
859
+ products={related}
860
+ heading='También te puede interesar'
861
+ productHref={(p) => `/shop/${p.slug}`}
862
+ />
863
+ ```
864
+
865
+ It renders the package's `ProductCard` by default. To use your own card, pass
866
+ `renderProduct`. Any item with an `id` works:
867
+
868
+ ```tsx
869
+ <ProductCarousel
870
+ products={related}
871
+ heading='También te puede interesar'
872
+ renderProduct={(p) => <MyProductCard product={p} />}
873
+ />
874
+ ```
875
+
876
+ - **Accessibility.** The track is a focusable `region` with
877
+ `aria-roledescription="carrusel"`. It is named by `heading` when you pass one,
878
+ otherwise by `labels.region`. Once the track has focus, the keyboard arrow
879
+ keys scroll it. The arrows are `<button>`s with `aria-label`s and
880
+ `aria-controls` pointing at the track. Under `prefers-reduced-motion: reduce`
881
+ they jump instead of animating.
882
+ - **Copy.** The defaults are Spanish (`DEFAULT_PRODUCT_CAROUSEL_LABELS`).
883
+ Override them with `labels={{ region, roleDescription, previous, next }}`.
884
+ - **Layout.** About 1.5 cards are visible on mobile, so the next one peeks in,
885
+ then 3 from `sm` and 4 from `lg`. Change the width with `itemClassName`
886
+ (e.g. `basis-64`). Style it with `className` or `[data-slot="product-carousel"]`.
887
+ `headingClassName` replaces the heading's typography (see the className rule
888
+ above). It renders nothing for an empty list.
889
+
739
890
  ## Page blocks
740
891
 
741
892
  `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 };