@reblu/site-blocks 0.13.0 → 0.14.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,13 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fff0bf5: REB-981: `CartAdjustmentsNotice` — presentational notice for what cart validation changed (removed lines, clamped quantities, changed prices), fed by `@reblu/site-next/cart`'s `useCartValidation()`. `CartLineList` gains an optional `lineMessage` prop to render a message under a line (e.g. its validation error). README replaces the `errors[0].message` guidance.
8
+ - b7667d4: Add `CourseDescription` — the drop-in for the rich-text course `description` from `client.academy.bySlug()` (REB-879). Server Component: sanitised Markdown (headings, GFM tables, images) through the `MarkdownRenderer` pipeline; renders nothing, not even its heading, when the description is null, blank or emptied by the sanitiser; an optional `heading` makes it a labelled `<section>` with the description's own headings demoted under it. README: snippet for the course page.
9
+ - b7667d4: Add `markdownToPlainText(markdown, { maxLength })` (root and `./seo` entries): rich-text Markdown → the plain text a reader sees, for meta/OG descriptions and excerpts. It runs the same sanitised pipeline as `MarkdownRenderer`, drops images, and cuts at a word boundary with `…`. README: how to render a rich-text course description and build its meta description (REB-879).
10
+
3
11
  ## 0.13.0
4
12
 
5
13
  ### Minor Changes
package/README.md CHANGED
@@ -8,16 +8,26 @@ their own. Every color, radius and font comes from CSS design tokens (OKLCH)
8
8
  that each tenant injects through its `site-theme.css`. The same package renders
9
9
  a different-looking site for every tenant, with zero forks.
10
10
 
11
+ **Required theme tokens.** Besides the usual shadcn tokens, the consuming
12
+ theme must define `--destructive-foreground` (text colour on `--destructive`,
13
+ ≥ 4.5:1 contrast) and map it in `@theme inline` as
14
+ `--color-destructive-foreground: var(--destructive-foreground);`. Tailwind v4
15
+ emits no CSS for `text-destructive-foreground` without it, so error states
16
+ (e.g. `AddToCartButton`'s `error` status) would render with inherited text
17
+ colour on the red background. See `docs/theming/theming.md`.
18
+
11
19
  ## What's inside
12
20
 
13
21
  | Export | Entry | Purpose |
14
22
  | ------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
15
23
  | `readingTime`, `formatPrice`, `buildShareUrls`, `markdownToSafeHtml` | `.` | Pure, framework-agnostic helpers (RSC-safe). |
24
+ | `markdownToPlainText` | `.` | Markdown → plain text for meta/OG descriptions (also on `./seo`). Same sanitiser as `MarkdownRenderer`. |
16
25
  | `sanitizeHtml` | `.` | Sanitises an HTML string before `dangerouslySetInnerHTML` — the **HTML boundary**. See "Security boundaries" below. |
17
26
  | `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
27
+ | `CourseDescription` | `.` | Drop-in for the rich-text course `description` (`client.academy.bySlug()`). Server Component. See "Rich-text descriptions". |
18
28
  | `Treatment`, `ParentPage`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. `Specialty`: deprecated alias, see below. |
19
29
  | `ShopFilters` | `.` | Plug-and-play shop filter chrome over `GET /api/site/shop/facets`. RSC-safe. See "Shop filters" below. |
20
- | `CartLineList`, `CartSummary`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
30
+ | `CartLineList`, `CartSummary`, `CartAdjustmentsNotice`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
21
31
  | `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
22
32
  | `ProductTrustItems`, `DEFAULT_TRUST_ICONS` | `.` | Store-wide trust messages from `ShopConfig.productTrustItems`. Server Component. See "Product trust items" below. |
23
33
  | `buildFaqPageJsonLd`, `collectFaqItems` | `./seo` | schema.org `FAQPage` structured data. Directive-free (also on `.`). See "Page blocks" below. |
@@ -199,7 +209,8 @@ for the wiring steps and a full example app).
199
209
  | `AddToCartButton` | `./client` | `onAddToCart?`, `disabled?`, `outOfStock?`, `pending?`, `error?`, `item?`, `successDurationMs?`, `onAdded?`, `onError?` | No — carries a built-in confirmed/error timer (`useState`/`useEffect`), see below |
200
210
  | `CartIcon` | `./client` | `itemCount`, `href`, `hydrated?`, `bumpOnAdd?` | No — the bump animation needs state, see below |
201
211
  | `AddedToCartToast` | `.` | `productName`, `cartHref`, `status?`, `quantity?`, `imageUrl?` | Yes |
202
- | `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?` | Yes |
212
+ | `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?`, `lineMessage?` | Yes |
213
+ | `CartAdjustmentsNotice` | `.` | `removed?`, `quantityChanges?`, `priceChanges?`, `labels?` | Yes |
203
214
  | `CartSummary` | `.` | `subtotal`, `discount?`, `shipping?`, `shippingEnabled?`, `total`, `notice?` | Yes |
204
215
  | `CheckoutLoginPrompt` | `.` | `open`, `email`, `onEmailChange`, `onClose`, `onSubmitEmail`, `onContinueAsGuest` | Yes (renders `null` when `open` is false) |
205
216
  | `CheckoutForm` | `.` | `contact`, `onContactChange`, `discountCode`, `onDiscountCodeChange`, `onApplyDiscount`, `onRemoveDiscount`, `onSubmit` | Yes |
@@ -352,12 +363,36 @@ For malformed bodies (which parse as `undefined`), pass a generic body such as
352
363
  `error.body ?? { error: 'Checkout failed' }` to show the fallback banner.
353
364
  An external SITE (including Dermamedicin) must adopt this wiring separately.
354
365
 
355
- `validate-cart`'s rejection path (out-of-stock, price-changed, empty cart)
356
- surfaces through `CheckoutForm`'s own error-display conventions: pass the
357
- first `ShopCartValidationError.message` from `client.shop.validateCart(...)`
358
- as a toast/banner in your page (the component itself has no cart-wide error
359
- slot — it only owns contact/discount field-level errors via `contactErrors`/
360
- `discountError`).
366
+ Do not surface validate-cart's rejection as the first
367
+ `ShopCartValidationError.message`: it does not say which line failed, and a
368
+ deleted product only comes back as «Producto desconocido». Use
369
+ `@reblu/site-next/cart`'s `useCartValidation()` (REB-981) on the cart and
370
+ checkout pages: it removes unavailable lines, clamps quantities and refreshes
371
+ prices in the store, and its result feeds `CartAdjustmentsNotice` and
372
+ `CartLineList`'s `lineMessage` — see "Cart adjustments" below and that
373
+ package's README.
374
+
375
+ ### Cart adjustments — `CartAdjustmentsNotice` (REB-981)
376
+
377
+ Presentational: tells the visitor what cart validation changed. Every prop is
378
+ optional and renders nothing when empty, so it can stay mounted.
379
+
380
+ ```tsx
381
+ <CartAdjustmentsNotice
382
+ removed={validation.removed} // [{ name, variantName? }]
383
+ quantityChanges={validation.quantityChanges} // [{ name, variantName?, to }]
384
+ priceChanges={validation.priceChanges} // [{ name, variantName?, from, to }] cents
385
+ />
386
+ ```
387
+
388
+ Default copy: «Hemos quitado del carrito: X, ya no está disponible.» (plural
389
+ «…ya no están disponibles.»), «Hemos ajustado la cantidad de X a N por el
390
+ stock disponible.», «El precio de X ha cambiado: antes A, ahora B.». Override
391
+ any of them with `labels={{ removed, quantityChanged, priceChanged }}` (each a
392
+ function). `currencyCode`/`locale` format the prices.
393
+
394
+ `CartLineList`'s `lineMessage={(item) => …}` renders a message under a line,
395
+ e.g. `validation.getLineError(item.productId, item.variantId)?.message`.
361
396
 
362
397
  ### `OrderConfirmation` states (REB-837)
363
398
 
@@ -699,6 +734,57 @@ called on the server (REB-846). `test/seo-entry.test.ts` guards this on the
699
734
  emitted `dist`. The caller serialises; `@reblu/site-next`'s `<JsonLd>` escapes
700
735
  the `</script>` boundary.
701
736
 
737
+ ### Rich-text descriptions on a page (`CourseDescription` + `markdownToPlainText`)
738
+
739
+ Some API fields are rich-text Markdown written in nexus with the shared editor
740
+ (headings, GFM tables, images) — for example a course `description` from
741
+ `client.academy.bySlug()` (REB-879). Never print them as plain text and never
742
+ pass them to `dangerouslySetInnerHTML`. On the course page, drop in
743
+ `CourseDescription`; for the meta description use `markdownToPlainText`, never a
744
+ slice of the raw Markdown (that leaks `##`, table pipes and `![…](…)` into search
745
+ snippets):
746
+
747
+ ```tsx
748
+ import { CourseDescription } from '@reblu/site-blocks'
749
+ import { markdownToPlainText } from '@reblu/site-blocks/seo'
750
+
751
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
752
+ const course = await client.academy.bySlug((await params).slug)
753
+ const description =
754
+ course.shortDescription || markdownToPlainText(course.description, { maxLength: 160 })
755
+ return { description: description || undefined }
756
+ }
757
+
758
+ export default async function CoursePage({ params }: Props) {
759
+ const course = await client.academy.bySlug((await params).slug)
760
+ return (
761
+ <article>
762
+ <h1>{course.title}</h1>
763
+ {/* The heading is your own copy, in your SITE's language. */}
764
+ <CourseDescription description={course.description} heading='Sobre el curso' />
765
+ </article>
766
+ )
767
+ }
768
+ ```
769
+
770
+ `CourseDescription` (Server Component, RSC-safe):
771
+
772
+ - runs the Markdown through the same sanitised pipeline as `MarkdownRenderer`
773
+ (`<script>`, event handlers, `javascript:` links and non-YouTube iframes are
774
+ dropped);
775
+ - renders **nothing**, not even the heading, for `null`, blank, or content the
776
+ sanitiser leaves empty, so a page never shows an empty "About the course";
777
+ - with `heading`, renders a `<section aria-labelledby>` and demotes the
778
+ description's own headings under it (`headingLevel`, default `2`; override with
779
+ `headingOffset`; `headingId` when the page renders it twice);
780
+ - styling hooks: `className`, `headingClassName`, `contentClassName`, and
781
+ `data-slot='course-description'` on the outer element.
782
+
783
+ For any other rich-text Markdown field, `MarkdownRenderer` is the generic
784
+ renderer (`headingOffset` demotes its headings under yours).
785
+ `markdownToPlainText` drops images and anything the sanitiser strips, and cuts
786
+ at a word boundary with `…` within `maxLength`.
787
+
702
788
  ## Security boundaries
703
789
 
704
790
  Two sinks, two different helpers, in two different packages — do not reach for