@reblu/site-blocks 0.12.1 → 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,59 @@
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
+
11
+ ## 0.13.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 614e826: New drop-in `FullInvoiceFields` (REB-73): the shop checkout's «¿Quieres
16
+ factura completa?» opt-in with the buyer's identifier (Spanish NIF/NIE/CIF,
17
+ EU VAT id, passport or ID document, with its issuing country), name and
18
+ billing address — required, without a toggle, when the cart total is over
19
+ `ShopConfig.simplifiedInvoiceCapCents`. Controlled and hook-free; with
20
+ `isOverSimplifiedInvoiceCap` and `toCheckoutInvoiceFields`, which builds the
21
+ `wantsFullInvoice`/`billing` checkout body fields.
22
+ - 614e826: Shop checkout: no full-invoice opt-in on a shop that issues no invoices (REB-73).
23
+ - `ShopErrorCode` gains `FULL_INVOICE_UNAVAILABLE` (HTTP 422,
24
+ `details.field: 'wantsFullInvoice'`): `wantsFullInvoice: true` sent to a shop
25
+ whose invoicing is off (`ShopConfig.simplifiedInvoiceCapCents` is `null`) is
26
+ refused before any payment instead of succeeding without an invoice. Clients
27
+ that never send the opt-in are unaffected. The union is additive: a SITE
28
+ with an exhaustive `switch` over `ShopErrorCode` (or a
29
+ `Record<ShopErrorCode, …>`) must add the new member when it upgrades.
30
+ - `@reblu/site-blocks`: new `shopIssuesInvoices(config)`; `FullInvoiceFields`
31
+ takes `issuesInvoices` (default `true`) and renders nothing when it is
32
+ `false`, and `toCheckoutInvoiceFields(value, overCap, issuesInvoices)`
33
+ returns no opt-in and no billing block then. Default Spanish copy for the
34
+ new code in `getShopErrorMessage`.
35
+
36
+ - 614e826: Shop: the order's invoice and the simplified-invoice cap (REB-73).
37
+ - `ShopOrder` gains the optional `invoice: { number, url } | null`
38
+ (`ShopOrderInvoice`): once the sale invoice is issued, its number and the
39
+ shop's authenticated client-portal route that serves it — never a signed
40
+ URL, so the link does not expire; the buyer signs in to open it.
41
+ - `ShopConfig` gains `simplifiedInvoiceCapCents` (cents, never above the legal
42
+ 300000; `null` while the shop issues no invoices): above it, checkout needs
43
+ the buyer's identifier, name and billing address.
44
+ - `@reblu/site-blocks`: new drop-in `OrderInvoiceLink`, which
45
+ `OrderConfirmation` now renders under a confirmed order (labels
46
+ `invoiceLinkLabel`/`invoiceSignInHint`), and default Spanish copy for the
47
+ `BILLING_DATA_REQUIRED`/`INVOICING_NOT_READY` checkout codes in
48
+ `getShopErrorMessage`.
49
+
50
+ ### Patch Changes
51
+
52
+ - Updated dependencies [614e826]
53
+ - Updated dependencies [614e826]
54
+ - Updated dependencies [614e826]
55
+ - @reblu/site-contracts@1.15.0
56
+
3
57
  ## 0.12.1
4
58
 
5
59
  ### Patch 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,11 +209,14 @@ 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 |
206
217
  | `OrderConfirmation` | `.` | `status`, `order`, `cartHref?`, `onRecheck?` | Yes |
218
+ | `OrderInvoiceLink` | `.` | `invoice` (`order.invoice`), `labels?` | Yes (renders `null` without an invoice) |
219
+ | `FullInvoiceFields` | `.` | `value`, `onChange`, `overCap`, `errors?`, `countries?`, `labels?` | Yes (controlled, no hooks) |
207
220
 
208
221
  **REB-837 breaking move**: `AddToCartButton` and `CartIcon` now carry real
209
222
  internal state (the confirmed/error timer, the bump-on-add guard) and ship
@@ -350,12 +363,36 @@ For malformed bodies (which parse as `undefined`), pass a generic body such as
350
363
  `error.body ?? { error: 'Checkout failed' }` to show the fallback banner.
351
364
  An external SITE (including Dermamedicin) must adopt this wiring separately.
352
365
 
353
- `validate-cart`'s rejection path (out-of-stock, price-changed, empty cart)
354
- surfaces through `CheckoutForm`'s own error-display conventions: pass the
355
- first `ShopCartValidationError.message` from `client.shop.validateCart(...)`
356
- as a toast/banner in your page (the component itself has no cart-wide error
357
- slot — it only owns contact/discount field-level errors via `contactErrors`/
358
- `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`.
359
396
 
360
397
  ### `OrderConfirmation` states (REB-837)
361
398
 
@@ -392,6 +429,78 @@ Every title/description is overridable via `labels` (see
392
429
  "`useShopOrderStatus` — the order-confirmation flow" for the full wiring
393
430
  recipe.
394
431
 
432
+ ### The order's invoice — `OrderInvoiceLink` (REB-73)
433
+
434
+ Once the sale invoice is issued, the order lookup (`client.shop.order(sessionId)`,
435
+ or `useShopOrderStatus`) carries `order.invoice: { number, url }` — `null`
436
+ while there is none. `url` is the shop's **authenticated** client-portal route,
437
+ never a signed URL: the buyer signs in to open the PDF, so the link never
438
+ expires and is safe to show anywhere. `OrderConfirmation` already renders it
439
+ under a confirmed order (`paid`/`found`/`timeout`), with its copy overridable
440
+ through `labels.invoiceLinkLabel` (`{number}` is replaced) and
441
+ `labels.invoiceSignInHint`. To place it elsewhere — an account page, a custom
442
+ confirmation — drop it in:
443
+
444
+ ```tsx
445
+ import { OrderInvoiceLink } from '@reblu/site-blocks'
446
+
447
+ // order: ShopOrder from client.shop.order(sessionId) / useShopOrderStatus
448
+ ;<OrderInvoiceLink invoice={order.invoice} />
449
+ // «Descargar la factura FAC-2026-0010» + «Te pediremos que inicies sesión para abrirla.»
450
+ ```
451
+
452
+ ### The full-invoice request — `FullInvoiceFields` (REB-73)
453
+
454
+ A full invoice needs the buyer's identifier — a Spanish NIF/NIE/CIF, or for a
455
+ foreign buyer an EU VAT id, a passport or an ID document of their country,
456
+ with the country that issued it — plus their name and billing address;
457
+ without it the invoice is simplified. Over `ShopConfig.simplifiedInvoiceCapCents`
458
+ a full invoice is the only legal one, so checkout refuses the sale without
459
+ them (`BILLING_DATA_REQUIRED`). Drop the fields into your checkout form and
460
+ send what they produce:
461
+
462
+ ```tsx
463
+ 'use client'
464
+ import { useState } from 'react'
465
+ import {
466
+ EMPTY_FULL_INVOICE_VALUE,
467
+ FullInvoiceFields,
468
+ isOverSimplifiedInvoiceCap,
469
+ shopIssuesInvoices,
470
+ toCheckoutInvoiceFields,
471
+ } from '@reblu/site-blocks'
472
+
473
+ const [invoice, setInvoice] = useState(EMPTY_FULL_INVOICE_VALUE)
474
+ const issuesInvoices = shopIssuesInvoices(shopConfig)
475
+ const overCap = isOverSimplifiedInvoiceCap(cartTotalCents, shopConfig.simplifiedInvoiceCapCents)
476
+
477
+ <FullInvoiceFields
478
+ value={invoice}
479
+ onChange={setInvoice}
480
+ overCap={overCap}
481
+ issuesInvoices={issuesInvoices}
482
+ />
483
+
484
+ // in onSubmit:
485
+ await client.shop.checkout({
486
+ ...checkoutBody,
487
+ ...toCheckoutInvoiceFields(invoice, overCap, issuesInvoices),
488
+ })
489
+ ```
490
+
491
+ A shop that issues no invoices (`ShopConfig.simplifiedInvoiceCapCents` is
492
+ `null`) offers no full invoice: pass `issuesInvoices={shopIssuesInvoices(config)}`
493
+ and the block renders nothing, and give the same flag to
494
+ `toCheckoutInvoiceFields(invoice, overCap, issuesInvoices)` so the body never
495
+ carries the opt-in.
496
+
497
+ Checkout refusals tied to invoicing come with their own default copy in
498
+ `getShopErrorMessage`: `BILLING_DATA_REQUIRED` (422 — a full invoice, or a
499
+ total over `ShopConfig.simplifiedInvoiceCapCents`, needs the buyer's
500
+ NIF/NIE or ID document, name and billing address), `INVOICING_NOT_READY`
501
+ (503) and `FULL_INVOICE_UNAVAILABLE` (422 — `wantsFullInvoice: true` sent to a
502
+ shop that issues no invoices).
503
+
395
504
  ## Product trust items
396
505
 
397
506
  `ProductTrustItems` renders the store-wide trust messages (shipping, returns,
@@ -625,6 +734,57 @@ called on the server (REB-846). `test/seo-entry.test.ts` guards this on the
625
734
  emitted `dist`. The caller serialises; `@reblu/site-next`'s `<JsonLd>` escapes
626
735
  the `</script>` boundary.
627
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
+
628
788
  ## Security boundaries
629
789
 
630
790
  Two sinks, two different helpers, in two different packages — do not reach for