@reblu/site-blocks 0.7.0 → 0.9.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,40 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 14814b0: Add a shared renderer for page registry blocks and FAQ structured data (REB-850).
8
+ - `PageBlocks` (root entry, Server Component): renders `PageDetail.blocks`,
9
+ applying each block's mirrored section `heading`, `backgroundColor` and `cta`.
10
+ Every block type's body is overridable through a `components` map keyed by
11
+ canonical type. Unknown block types and embeds with `resolved: null` render
12
+ nothing; the deprecated `doctor_embed`/`specialty_embed` aliases render
13
+ through the `profile_embed`/`page_embed` renderers. Blocks render in `order`;
14
+ a `cta` slot with no heading, no usable CTA and no override renders nothing.
15
+ Pass `idPrefix` when a page renders more than one `PageBlocks` instance, so
16
+ section heading ids stay unique.
17
+ - `FaqAccordion` (root entry, Server Component): native `<details>`/`<summary>`
18
+ FAQ list with configurable heading levels; no JavaScript required.
19
+ - `buildFaqPageJsonLd` and `collectFaqItems` on the new directive-free
20
+ `@reblu/site-blocks/seo` subpath (also on the root): build a schema.org
21
+ `FAQPage` object, callable from Server Components. Questions follow block
22
+ `order` and keep the author's plain text (whitespace collapsed only), so the
23
+ structured data matches what `FaqAccordion` shows.
24
+
25
+ Additive only — no existing export changes.
26
+
27
+ ## 0.8.0
28
+
29
+ ### Minor Changes
30
+
31
+ - c40434a: Add a distributable controlled phone input and E.164 normalization. Preserve and validate the additive checkout `details.field` in typed API errors so CheckoutForm can mark the rejected phone while retaining generic error fallback.
32
+
33
+ ### Patch Changes
34
+
35
+ - Updated dependencies [c40434a]
36
+ - @reblu/site-ui@0.7.0
37
+
3
38
  ## 0.7.0
4
39
 
5
40
  ### Minor Changes
package/README.md CHANGED
@@ -10,18 +10,20 @@ a different-looking site for every tenant, with zero forks.
10
10
 
11
11
  ## What's inside
12
12
 
13
- | Export | Entry | Purpose |
14
- | ------ | ----- | ------- |
15
- | `readingTime`, `formatPrice`, `buildShareUrls`, `markdownToSafeHtml` | `.` | Pure, framework-agnostic helpers (RSC-safe). |
16
- | `sanitizeHtml` | `.` | Sanitises an HTML string before `dangerouslySetInnerHTML` — the **HTML boundary**. See "Security boundaries" below. |
17
- | `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
18
- | `Treatment`, `Specialty`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. |
19
- | `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. |
21
- | `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
22
- | `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
23
- | `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
24
- | `AddToCartButton`, `CartIcon` | `./client` | Add-to-cart feedback (REB-837) — **moved here from `.`**, see below. |
13
+ | Export | Entry | Purpose |
14
+ | ------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
15
+ | `readingTime`, `formatPrice`, `buildShareUrls`, `markdownToSafeHtml` | `.` | Pure, framework-agnostic helpers (RSC-safe). |
16
+ | `sanitizeHtml` | `.` | Sanitises an HTML string before `dangerouslySetInnerHTML` — the **HTML boundary**. See "Security boundaries" below. |
17
+ | `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
18
+ | `Treatment`, `Specialty`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. |
19
+ | `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. |
21
+ | `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
22
+ | `buildFaqPageJsonLd`, `collectFaqItems` | `./seo` | schema.org `FAQPage` structured data. Directive-free (also on `.`). See "Page blocks" below. |
23
+ | `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
24
+ | `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
25
+ | `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
26
+ | `AddToCartButton`, `CartIcon` | `./client` | Add-to-cart feedback (REB-837) — **moved here from `.`**, see below. |
25
27
 
26
28
  ## Boundary
27
29
 
@@ -44,7 +46,7 @@ Calle Mayor 1
44
46
  ```
45
47
 
46
48
  renders as one paragraph reading `Calle Mayor 1 28001 Madrid`. This is standard
47
- CommonMark — a lone newline is a *soft* break, i.e. whitespace — and it is what
49
+ CommonMark — a lone newline is a _soft_ break, i.e. whitespace — and it is what
48
50
  this renderer has done since it replaced the interim, non-standard one that
49
51
  emitted a `<br>` for every newline. **Text written under the old behaviour reads
50
52
  differently now**, so addresses, signature blocks and enumerated clauses typed on
@@ -52,11 +54,11 @@ consecutive lines are worth re-reading.
52
54
 
53
55
  To force a visible break, use any of:
54
56
 
55
- | Want | Write |
56
- | ---- | ----- |
57
- | A new paragraph | A blank line between the two lines |
58
- | A hard break inside one paragraph | Two spaces at the end of the first line |
59
- | A hard break, visibly | A trailing `\` at the end of the first line |
57
+ | Want | Write |
58
+ | --------------------------------- | ------------------------------------------- |
59
+ | A new paragraph | A blank line between the two lines |
60
+ | A hard break inside one paragraph | Two spaces at the end of the first line |
61
+ | A hard break, visibly | A trailing `\` at the end of the first line |
60
62
 
61
63
  ### `hidden` hides a block — it does not withhold it
62
64
 
@@ -191,16 +193,16 @@ dependency of their own. Wire them to `@reblu/site-next/cart`'s `useCart()`
191
193
  and the `./shop-route-handlers` Route Handlers (see that package's README
192
194
  for the wiring steps and a full example app).
193
195
 
194
- | Component | Entry | Props it needs | RSC-safe without callbacks? |
195
- | --------- | ----- | --------------- | ---------------------------- |
196
- | `AddToCartButton` | `./client` | `onAddToCart?`, `disabled?`, `outOfStock?`, `pending?`, `error?`, `item?`, `successDurationMs?`, `onAdded?`, `onError?` | No — carries a built-in confirmed/error timer (`useState`/`useEffect`), see below |
197
- | `CartIcon` | `./client` | `itemCount`, `href`, `hydrated?`, `bumpOnAdd?` | No — the bump animation needs state, see below |
198
- | `AddedToCartToast` | `.` | `productName`, `cartHref`, `status?`, `quantity?`, `imageUrl?` | Yes |
199
- | `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?` | Yes |
200
- | `CartSummary` | `.` | `subtotal`, `discount?`, `shipping?`, `shippingEnabled?`, `total`, `notice?` | Yes |
201
- | `CheckoutLoginPrompt` | `.` | `open`, `email`, `onEmailChange`, `onClose`, `onSubmitEmail`, `onContinueAsGuest` | Yes (renders `null` when `open` is false) |
202
- | `CheckoutForm` | `.` | `contact`, `onContactChange`, `discountCode`, `onDiscountCodeChange`, `onApplyDiscount`, `onRemoveDiscount`, `onSubmit` | Yes |
203
- | `OrderConfirmation` | `.` | `status`, `order`, `cartHref?`, `onRecheck?` | Yes |
196
+ | Component | Entry | Props it needs | RSC-safe without callbacks? |
197
+ | --------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
198
+ | `AddToCartButton` | `./client` | `onAddToCart?`, `disabled?`, `outOfStock?`, `pending?`, `error?`, `item?`, `successDurationMs?`, `onAdded?`, `onError?` | No — carries a built-in confirmed/error timer (`useState`/`useEffect`), see below |
199
+ | `CartIcon` | `./client` | `itemCount`, `href`, `hydrated?`, `bumpOnAdd?` | No — the bump animation needs state, see below |
200
+ | `AddedToCartToast` | `.` | `productName`, `cartHref`, `status?`, `quantity?`, `imageUrl?` | Yes |
201
+ | `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?` | Yes |
202
+ | `CartSummary` | `.` | `subtotal`, `discount?`, `shipping?`, `shippingEnabled?`, `total`, `notice?` | Yes |
203
+ | `CheckoutLoginPrompt` | `.` | `open`, `email`, `onEmailChange`, `onClose`, `onSubmitEmail`, `onContinueAsGuest` | Yes (renders `null` when `open` is false) |
204
+ | `CheckoutForm` | `.` | `contact`, `onContactChange`, `discountCode`, `onDiscountCodeChange`, `onApplyDiscount`, `onRemoveDiscount`, `onSubmit` | Yes |
205
+ | `OrderConfirmation` | `.` | `status`, `order`, `cartHref?`, `onRecheck?` | Yes |
204
206
 
205
207
  **REB-837 breaking move**: `AddToCartButton` and `CartIcon` now carry real
206
208
  internal state (the confirmed/error timer, the bump-on-add guard) and ship
@@ -316,6 +318,26 @@ discount-code flow. Render them via `CheckoutForm`'s `children` slot
316
318
  (rendered between the contact and discount sections) if your shop has
317
319
  shipping enabled.
318
320
 
321
+ For phone normalization, pass `phoneRegion` from the tenant's configured country:
322
+ the controlled `contact.phone` callback receives E.164 when valid and raw text
323
+ when incomplete. On a checkout API rejection pass the parsed response body to
324
+ `checkoutError` (not the transport error's prefixed `message`). Only an
325
+ `INVALID_INPUT` body with `details.field === 'customerPhone'` and a nonblank
326
+ `error` marks the phone field. Unknown/missing/malformed details show the
327
+ existing generic code message instead; other code paths are unchanged. Clear
328
+ the response on edit/retry in the consuming SITE to remove a stale server error.
329
+
330
+ ```tsx
331
+ <CheckoutForm {...checkoutProps} phoneRegion='ES' checkoutError={checkoutErrorBody} />
332
+ ```
333
+
334
+ Pass `error.body` after catching `error instanceof ApiError` from
335
+ `@reblu/site-client`. Its `body` parser now types and validates the optional
336
+ string `details.field`; never parse the transport-prefixed `error.message`.
337
+ For malformed bodies (which parse as `undefined`), pass a generic body such as
338
+ `error.body ?? { error: 'Checkout failed' }` to show the fallback banner.
339
+ An external SITE (including Dermamedicin) must adopt this wiring separately.
340
+
319
341
  `validate-cart`'s rejection path (out-of-stock, price-changed, empty cart)
320
342
  surfaces through `CheckoutForm`'s own error-display conventions: pass the
321
343
  first `ShopCartValidationError.message` from `client.shop.validateCart(...)`
@@ -330,17 +352,17 @@ directly — `loading`/`paid` are drop-in aliases for the original
330
352
  `polling`/`found` states, so existing callers on the pre-REB-837 3-state API
331
353
  (`'polling' | 'found' | 'timeout'`) are completely unaffected.
332
354
 
333
- | `status` | Default copy | Order details shown | Extra UI |
334
- | -------- | ------------- | -------------------- | -------- |
335
- | `loading` / `polling` | «Confirmando tu pedido…» | — | — |
336
- | `paid` / `found` | «¡Pedido confirmado!» | Full summary (items, total, confirmation-email line) | — |
337
- | `failed` | «El pago no se ha completado» | None | "Volver al carrito" link when `cartHref` is given |
338
- | `refunded` | «Este pedido ha sido reembolsado» | Order number only (neutral reference) | — |
339
- | `partially_refunded` | «…reembolsado parcialmente» | Order number only | — |
340
- | `pending_timeout` | «Pedido recibido. Estamos confirmando el pago…» | Order number only | "Volver a comprobar" button when `onRecheck` is given |
341
- | `not_found` | «No encontramos ningún pedido» | None | — |
342
- | `error` | «Ha ocurrido un error» | None | "Volver a comprobar" button when `onRecheck` is given |
343
- | `timeout` (legacy) | «Pago recibido» — UNCHANGED | Full summary (unchanged pre-REB-837 behavior) | — |
355
+ | `status` | Default copy | Order details shown | Extra UI |
356
+ | --------------------- | ----------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------- |
357
+ | `loading` / `polling` | «Confirmando tu pedido…» | — | — |
358
+ | `paid` / `found` | «¡Pedido confirmado!» | Full summary (items, total, confirmation-email line) | — |
359
+ | `failed` | «El pago no se ha completado» | None | "Volver al carrito" link when `cartHref` is given |
360
+ | `refunded` | «Este pedido ha sido reembolsado» | Order number only (neutral reference) | — |
361
+ | `partially_refunded` | «…reembolsado parcialmente» | Order number only | — |
362
+ | `pending_timeout` | «Pedido recibido. Estamos confirmando el pago…» | Order number only | "Volver a comprobar" button when `onRecheck` is given |
363
+ | `not_found` | «No encontramos ningún pedido» | None | — |
364
+ | `error` | «Ha ocurrido un error» | None | "Volver a comprobar" button when `onRecheck` is given |
365
+ | `timeout` (legacy) | «Pago recibido» — UNCHANGED | Full summary (unchanged pre-REB-837 behavior) | — |
344
366
 
345
367
  **REB-837 review fix**: the full order summary (items, total, "te hemos
346
368
  enviado un email de confirmación a…") renders ONLY for `paid`/`found` and
@@ -358,15 +380,122 @@ Every title/description is overridable via `labels` (see
358
380
  "`useShopOrderStatus` — the order-confirmation flow" for the full wiring
359
381
  recipe.
360
382
 
383
+ ## Page blocks
384
+
385
+ `PageBlocks` renders a page's registry blocks (`PageDetail.blocks` from
386
+ `GET /api/site/pages/[slug]`) so a SITE does not hand-write its own block
387
+ switch. It is a **Server Component** — no hooks, no `'use client'` — exported
388
+ from the RSC-safe root entry.
389
+
390
+ ```tsx
391
+ import { PageBlocks } from '@reblu/site-blocks'
392
+ import { buildFaqPageJsonLd, collectFaqItems } from '@reblu/site-blocks/seo'
393
+ import { JsonLd } from '@reblu/site-next/json-ld'
394
+
395
+ export default async function Page({ params }) {
396
+ const page = await getPage((await params).slug)
397
+ const faq = buildFaqPageJsonLd(collectFaqItems(page.blocks))
398
+ return (
399
+ <main>
400
+ <h1>{page.title}</h1>
401
+ <PageBlocks blocks={page.blocks} embedHref={(r) => `/${r.slug}`} />
402
+ {faq && <JsonLd id='faq' data={faq} />}
403
+ </main>
404
+ )
405
+ }
406
+ ```
407
+
408
+ Each block is wrapped in a `<section data-slot="page-block" data-block-type="…">`
409
+ that applies the parent section's mirrored fields (REB-849):
410
+
411
+ - **`heading`** — rendered at `headingLevel` (default `2`) and used as the
412
+ section's accessible name. Headings inside the block go one level deeper.
413
+ - **`backgroundColor`** — applied as the section background. Theme tokens
414
+ (`var(--primary)`, `var(--secondary)`, `var(--accent)`) get their paired
415
+ `-foreground` token; a custom hex gets white or near-black text by WCAG
416
+ luminance; any other value keeps the theme foreground.
417
+ - **`cta`** — rendered after the body as a link styled with `buttonVariants`;
418
+ external CTAs open in a new tab with `rel="noopener noreferrer"`.
419
+
420
+ | Prop | Default | Purpose |
421
+ | ------------------- | -------------------- | ---------------------------------------------------------- |
422
+ | `blocks` | — | `PageDetail.blocks`. `null`/empty renders nothing. |
423
+ | `components` | `{}` | Per-type body renderer overrides (see below). |
424
+ | `headingLevel` | `2` | Level of each section heading. |
425
+ | `embedHref` | `` r => `/${r.slug}` `` | Href of an embedded page — route it per `r.pageType`. |
426
+ | `idPrefix` | `'page-block'` | Namespace for heading ids; pass distinct values when one document renders `PageBlocks` twice with the same blocks. |
427
+ | `className`, `sectionClassName` | — | Extra classes on the list / on each section. |
428
+
429
+ ### Fail-closed rules
430
+
431
+ - An **unknown `blockType`** (e.g. a type added to the API before this package
432
+ is upgraded) renders nothing — not even its heading — and never throws.
433
+ - An **embed** (`page_embed`, `profile_embed`) whose `resolved` is `null` or
434
+ absent renders nothing: no heading, no dead link.
435
+ - A **`cta` block** references a live CTA by `data.slot`, which only the SITE
436
+ can resolve; by default it renders just the section heading and mirrored
437
+ CTA, and nothing at all when it has neither. Supply `components.cta` to
438
+ render the slot.
439
+ - **Deprecated aliases** `doctor_embed` and `specialty_embed` (REB-852) render
440
+ through the `profile_embed` and `page_embed` renderers respectively.
441
+
442
+ ### Overriding a block type
443
+
444
+ `components` is a partial map keyed by **canonical** block type. An override
445
+ replaces only the block body; the section wrapper (heading, background, CTA)
446
+ still applies.
447
+
448
+ ```tsx
449
+ import { PageBlocks, type BlockRendererProps } from '@reblu/site-blocks'
450
+
451
+ function TeamCard({ block, headingLevel, embedHref }: BlockRendererProps<'profile_embed'>) {
452
+ // `block.resolved` is never null here; `block.blockType` may be the deprecated alias.
453
+ return <a href={embedHref(block.resolved)}>{block.resolved.title}</a>
454
+ }
455
+
456
+ <PageBlocks blocks={page.blocks} components={{ profile_embed: TeamCard }} />
457
+ ```
458
+
459
+ Every renderer receives `{ block, headingLevel, embedHref }`. Overrides may be
460
+ async Server Components, or Client Components when they need interactivity.
461
+
462
+ ### `FaqAccordion`
463
+
464
+ Native `<details>`/`<summary>` — opens and closes with no JavaScript, keyboard
465
+ and screen-reader semantics come from the browser. Answers render as text,
466
+ never as markup; line breaks are kept.
467
+
468
+ | Prop | Default | Purpose |
469
+ | ---------------------- | ------- | ------------------------------------------------------------- |
470
+ | `items` | — | `{ q, a }[]` — the `faq` block's `data.items`. |
471
+ | `heading` | — | Optional heading above the list. |
472
+ | `headingLevel` | `2` | Level of `heading`. |
473
+ | `questionHeadingLevel` | — | When set, wraps each question in `<h{n}>` inside its summary. |
474
+ | `defaultOpen` | `[]` | Indexes of items open on first render. |
475
+
476
+ ### `buildFaqPageJsonLd(items)`
477
+
478
+ Returns a schema.org `FAQPage` object (or `null` when no item has both a
479
+ question and an answer — an empty `FAQPage` is invalid). Questions and answers
480
+ are plain text and pass through verbatim — only whitespace is collapsed — so the
481
+ structured data says exactly what `FaqAccordion` shows (`a < b` survives).
482
+ `collectFaqItems` orders blocks by `order` with the same helper `PageBlocks`
483
+ uses, so question order matches the page. It is a pure function exported from the directive-free
484
+ `@reblu/site-blocks/seo` entry, so Server Components and `generateMetadata` can
485
+ call it — a helper reachable only through a `'use client'` entry cannot be
486
+ called on the server (REB-846). `test/seo-entry.test.ts` guards this on the
487
+ emitted `dist`. The caller serialises; `@reblu/site-next`'s `<JsonLd>` escapes
488
+ the `</script>` boundary.
489
+
361
490
  ## Security boundaries
362
491
 
363
492
  Two sinks, two different helpers, in two different packages — do not reach for
364
493
  one where the other belongs:
365
494
 
366
- | Sink | Helper | Package |
367
- | ---- | ------ | ------- |
368
- | HTML rendered via `dangerouslySetInnerHTML` | `sanitizeHtml` (strip/allowlist sanitiser) | `@reblu/site-blocks` (here) |
369
- | `<script type="application/ld+json">` body | `jsonLdScriptContent` / `<JsonLd>` (escaper) | [`@reblu/site-next/json-ld`](../site-next#structured-data-json-ld) |
495
+ | Sink | Helper | Package |
496
+ | ------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------ |
497
+ | HTML rendered via `dangerouslySetInnerHTML` | `sanitizeHtml` (strip/allowlist sanitiser) | `@reblu/site-blocks` (here) |
498
+ | `<script type="application/ld+json">` body | `jsonLdScriptContent` / `<JsonLd>` (escaper) | [`@reblu/site-next/json-ld`](../site-next#structured-data-json-ld) |
370
499
 
371
500
  A `<script>` body is HTML's **raw-text** context: the parser ends it on the
372
501
  first `</script` byte sequence regardless of whether that sequence sits inside