@reblu/site-blocks 0.30.0 → 0.32.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,42 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.32.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 93143eb: Reviews (REB-1272). `@reblu/site-contracts` exports the `Review` and `ReviewSource` types of `GET /api/site/reviews`. `@reblu/site-client` adds `client.reviews.list({ pageId?, limit? }, cache?)`: the tenant's published reviews in the order set in nexus, optionally only one page's (404 without the tenant's `reviews` module). `@reblu/site-blocks` adds `Reviews`, a Server Component drop-in: a responsive grid with an accessible star rating, the author, the date, the text and a «Ver en <fuente>» link to the original (new tab, `rel="noopener nofollow"`), plus an optional tenant `notice` above or below the grid. Every existing export and namespace is unchanged.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [25a6af3]
12
+ - Updated dependencies [93143eb]
13
+ - @reblu/site-contracts@1.30.0
14
+
15
+ ## 0.31.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 9a83295: Guest course checkout (REB-1264). Additive only: every existing export behaves as before.
20
+ - `@reblu/site-contracts` exports `AcademyCheckoutRequest`, `AcademyCheckoutBilling`, `AcademyCheckoutSession` and `AcademyCheckoutErrorCode` (`POST /api/site/academy/checkout`).
21
+ - `@reblu/site-client` adds `client.academy.checkout(body, options?)` → `{ url, sessionId }` (Stripe URL re-checked like `shop.checkout()`); a refusal throws `AcademyCheckoutError` (an `ApiError`) with the stable `code` and `details`.
22
+ - `@reblu/site-next` adds `createAcademyCheckoutRoute()` in `./route-handlers`: forwards `x-reblu-visitor-ip`, fills `successUrl`/`cancelUrl` on `coursePath` and re-checks the Stripe URL. `createShopCheckoutRoute` now shares that code, unchanged in behaviour.
23
+ - `@reblu/site-blocks` adds the drop-in `CourseGuestCheckoutForm` (`./client`): name, surname, email, terms box, the course's consent box, optional full invoice, total and pay notice; it gives way to the request form on `ENROLLMENT_REQUEST_REQUIRED`. The root entry adds `courseSellsAsGuest` and `toAcademyCheckoutInvoiceFields`.
24
+
25
+ - faf78e8: Checkout pickup point (REB-1276). `CheckoutDeliveryChoice`: switching to pickup with exactly one pickup point now selects it (`pickupLocationId` is sent); with two or more points none is preselected, as before. New `isCheckoutDeliveryComplete(value, requiresDelivery?)` tells whether the choice can be quoted (pickup needs a point), so the SITE passes `null` to `useShopQuote` instead of asking for a quote known to answer 422. New `checkoutQuoteNotice({ fulfillment, requiresDelivery?, error? })` and `<CheckoutQuoteNotice reason labels? />` render «Elige un punto de recogida» (`role=status`) for an incomplete pickup and for the `PICKUP_LOCATION_INVALID` quote error, instead of the generic total error. Also exports `DEFAULT_CHECKOUT_QUOTE_NOTICE_LABELS` and the `CheckoutQuoteNotice*` types. All additive.
26
+ - faf78e8: `OrderConfirmation` default copy change (REB-1200, approved by the owner). The confirmed-order line no longer says «Te hemos enviado un email con tu acceso.» (wrong for physical orders and for buyers who already have an account). The default `foundDescription` is now «Tu pedido {number} está confirmado. Te hemos enviado el resumen a {email}.»: `{number}` and `{email}` are filled from the order. When the text carries `{email}`, the separate «Te hemos enviado un email de confirmación a…» line is not shown. New label `foundWithoutOrderDescription` («Tu pedido está confirmado.») is used while no order is loaded, so a placeholder is never shown raw. A SITE that passes its own `labels.foundDescription` is unaffected: a text without placeholders renders as is, with the confirmation-email line as before.
27
+ - 49f8f4b: New page content blocks (REB-1265..1268). Additive only: every existing block type renders exactly as before.
28
+ - `before_after` («Antes y después»): before/after image pairs with an opt-in text under them (`showCaption` + `caption`). `@reblu/site-blocks` adds the drop-in `BeforeAfterPairs` and renders the block in `PageBlocks`. `@reblu/site-contracts` exports `BeforeAfterData`.
29
+ - `chips` («Etiquetas»): a short list of texts shown as chips. Drop-in `ChipList`; type `ChipsData`.
30
+ - `icon_cards` («Tarjetas con icono»): a responsive grid of cards with an icon from a fixed, curated set, a title and a short text. Drop-in `IconCardGrid` and `CardIconSvg`, `CARD_ICON_NAMES`; types `IconCardsData` and `CardIcon`. `feature_list` is unchanged.
31
+ - `image_text` («Imagen y texto»): an image beside rich text, image left or right, configurable mobile order. Drop-in `ImageTextSplit`; type `ImageTextData`. `BlockRendererProps` gains an optional `headingId`, set only for `image_text`, which draws the section heading in its text column. The legacy `TEXT_IMAGE`/`IMAGE_TEXT` layouts are unchanged.
32
+ - `PageBlocks` renders `data.kicker` above the section heading for the new content blocks only.
33
+
34
+ ### Patch Changes
35
+
36
+ - Updated dependencies [9a83295]
37
+ - Updated dependencies [49f8f4b]
38
+ - @reblu/site-contracts@1.29.0
39
+
3
40
  ## 0.30.0
4
41
 
5
42
  ### Minor Changes
package/README.md CHANGED
@@ -36,6 +36,7 @@ colour on the red background. See `docs/theming/theming.md`.
36
36
  | `ProductTrustItems`, `DEFAULT_TRUST_ICONS` | `.` | Store-wide trust messages from `ShopConfig.productTrustItems`. Server Component. See "Product trust items" below. |
37
37
  | `TrustBadges`, `DEFAULT_TRUST_BADGE_HEADINGS` | `.` | The tenant's trust badges from `client.trustBadges.list()`, grouped by type. Server Component. See "Trust badges" below. |
38
38
  | `ArbitrationBadge` | `.` | The consumer-arbitration distinctive with its adhesion number, for the footer. Server Component. See "Consumer arbitration badge" below. |
39
+ | `Reviews` | `.` | The tenant's published, verifiable reviews from `client.reviews.list()`. Server Component. See "Reviews" below. |
39
40
  | `buildFaqPageJsonLd`, `collectFaqItems` | `./seo` | schema.org `FAQPage` structured data. Directive-free (also on `.`). See "Page blocks" below. |
40
41
  | `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
41
42
  | `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
@@ -571,7 +572,7 @@ directly — `loading`/`paid` are drop-in aliases for the original
571
572
  | `status` | Default copy | Order details shown | Extra UI |
572
573
  | --------------------- | ----------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------- |
573
574
  | `loading` / `polling` | «Confirmando tu pedido…» | — | — |
574
- | `paid` / `found` | «¡Pedido confirmado!» | Full summary (items, total, confirmation-email line) | — |
575
+ | `paid` / `found` | «¡Pedido confirmado!» + «Tu pedido {number} está confirmado. Te hemos enviado el resumen a {email}.» | Full summary (items, total; the confirmation-email line only when `foundDescription` has no `{email}`) | — |
575
576
  | `failed` | «El pago no se ha completado» | None | "Volver al carrito" link when `cartHref` is given |
576
577
  | `refunded` | «Este pedido ha sido reembolsado» | Order number only (neutral reference) | — |
577
578
  | `partially_refunded` | «…reembolsado parcialmente» | Order number only | — |
@@ -591,6 +592,18 @@ reference (`labels.orderReferenceLabel`); `failed`/`not_found`/`error`
591
592
  render no order details at all, even if an `order` object happens to be
592
593
  present.
593
594
 
595
+ **REB-1200 — default copy change.** The confirmed line used to read «Te
596
+ hemos enviado un email con tu acceso.» for every order, physical ones too, and
597
+ buyers who already had an account got no new access. The default
598
+ `foundDescription` is now «Tu pedido {number} está confirmado. Te hemos
599
+ enviado el resumen a {email}.» (`{number}` = `order.orderNumber`, `{email}` =
600
+ `order.customerEmail`). Because it names the email, the separate «Te hemos
601
+ enviado un email de confirmación a…» line is not shown under it. With no order
602
+ loaded yet, `foundWithoutOrderDescription` («Tu pedido está confirmado.») is
603
+ shown instead of a raw placeholder. A SITE that passes its own
604
+ `labels.foundDescription` is unaffected: a text without `{email}` keeps the
605
+ confirmation-email line, exactly as before.
606
+
594
607
  Every title/description is overridable via `labels` (see
595
608
  `OrderConfirmationLabels`). See `@reblu/site-next`'s README
596
609
  "`useShopOrderStatus` — the order-confirmation flow" for the full wiring
@@ -627,6 +640,7 @@ configuration edited in nexus (`ShopConfig.delivery`, `ShopConfig.checkout`,
627
640
  | Block | Reads | Renders |
628
641
  | ----- | ----- | ------- |
629
642
  | `CheckoutDeliveryChoice` | `config.delivery`, `config.checkout` | Home delivery (cost, estimate, address form) or free pickup (points with address, hours, ready-in text). Only the methods the tenant offers; nothing for a cart that ships nothing. |
643
+ | `CheckoutQuoteNotice` | `checkoutQuoteNotice(...)` | «Elige un punto de recogida» (`role=status`) in place of the summary and of the total error while pickup has no point, or when the quote answers `PICKUP_LOCATION_INVALID` (REB-1276). |
630
644
  | `CheckoutQuoteSummary` | `ShopQuote` | Lines (kind, `qty × unit`), subtotal, discount, delivery row, tax rows, total, «Impuestos incluidos», remaining for free shipping, delivery term; `role=status` for an unusable code, `role=alert` when the order cannot be paid. |
631
645
  | `CheckoutTermsAcceptance` | `config.checkout` | The required, unticked box with `{terms}` as the link to the full document and `{tenant}` filled. Nothing when no terms are published. |
632
646
  | `CheckoutPayAction` | `config.checkout.continueToPaymentLabel`, `quote.payNotice` | The submit button and the notice with the amount under it (`text-sm`, the button's description). |
@@ -644,11 +658,14 @@ import type { ShopConfig } from '@reblu/site-contracts'
644
658
  import {
645
659
  CheckoutDeliveryChoice,
646
660
  CheckoutPayAction,
661
+ CheckoutQuoteNotice,
647
662
  CheckoutQuoteSummary,
648
663
  CheckoutTermsAcceptance,
649
664
  EMPTY_CHECKOUT_ADDRESS,
650
665
  defaultCheckoutFulfillment,
666
+ checkoutQuoteNotice,
651
667
  getShopErrorMessage,
668
+ isCheckoutDeliveryComplete,
652
669
  quoteRequiresDelivery,
653
670
  toCheckoutFulfillmentFields,
654
671
  toCheckoutTermsFields,
@@ -660,9 +677,13 @@ export function SingleStepCheckout({ config }: { config: ShopConfig }) {
660
677
  const [address, setAddress] = useState(EMPTY_CHECKOUT_ADDRESS)
661
678
  const [acceptsTerms, setAcceptsTerms] = useState(false)
662
679
  const lines = items.map(({ productId, variantId, quantity }) => ({ productId, variantId, quantity }))
680
+ // Pickup with no point is known to be incomplete: ask no quote for it (REB-1276).
663
681
  const { quote, status, error, refresh } = useShopQuote(
664
- lines.length ? { items: lines, fulfillment: toCheckoutFulfillmentFields(fulfillment, address).fulfillment } : null,
682
+ lines.length && isCheckoutDeliveryComplete(fulfillment)
683
+ ? { items: lines, fulfillment: toCheckoutFulfillmentFields(fulfillment, address).fulfillment }
684
+ : null,
665
685
  )
686
+ const notice = checkoutQuoteNotice({ fulfillment, requiresDelivery: quoteRequiresDelivery(quote), error })
666
687
  // A cart that ships nothing hides the choice: send no address (blank = 400).
667
688
  const delivery = toCheckoutFulfillmentFields(fulfillment, address, quoteRequiresDelivery(quote))
668
689
 
@@ -704,8 +725,9 @@ export function SingleStepCheckout({ config }: { config: ShopConfig }) {
704
725
  ) : null}
705
726
  </div>
706
727
  <aside className='space-y-5'>
707
- {quote ? <CheckoutQuoteSummary quote={quote} heading='Resumen' /> : null}
708
- {status === 'error' ? (
728
+ {notice ? <CheckoutQuoteNotice reason={notice} /> : null}
729
+ {!notice && quote ? <CheckoutQuoteSummary quote={quote} heading='Resumen' /> : null}
730
+ {!notice && status === 'error' ? (
709
731
  <p role='alert'>
710
732
  {error?.message ?? 'No hemos podido calcular el total.'}{' '}
711
733
  <button type='button' onClick={() => void refresh()}>Reintentar</button>
@@ -751,6 +773,14 @@ Notes:
751
773
  of the hidden block (the server refuses a blank address with 400): the server records no delivery
752
774
  for such a cart, and the request stays on the current contract (REB-1227
753
775
  removes the old shape).
776
+ - Pickup point (REB-1276): switching to pickup with exactly one point selects
777
+ it (`pickupLocationId` is sent); with two or more, none is preselected.
778
+ While pickup has no point, `isCheckoutDeliveryComplete(fulfillment)` is
779
+ `false`: pass `null` to `useShopQuote` (no request that is known to answer
780
+ 422) and render `CheckoutQuoteNotice` with `checkoutQuoteNotice(...)`. The
781
+ same notice covers a `PICKUP_LOCATION_INVALID` quote error (a point
782
+ deactivated meanwhile), never the generic total error. Replace its text with
783
+ `labels={{ pickupLocationRequired: '…' }}`.
754
784
  - The full-invoice texts are the tenant's too: pass
755
785
  `labels={{ toggle: config.checkout.fullInvoiceCheckboxLabel, underCapNotice: config.checkout.fullInvoiceHelpText }}`
756
786
  to `FullInvoiceFields`.
@@ -898,6 +928,56 @@ import { Truck } from 'my-icon-set'
898
928
  `icons` cannot add a new icon key: an item whose `icon` this version does not
899
929
  know is still skipped.
900
930
 
931
+ ## Reviews
932
+
933
+ `Reviews` (REB-1272) renders the tenant's published reviews, managed in nexus
934
+ («Reseñas») and served by `client.reviews.list()`: a responsive grid of cards
935
+ with the stars, the text, the author, the date and a «Ver en Google» link to
936
+ the original review. Publishing, hiding or reordering a review in nexus changes
937
+ the SITE on the next revalidation, with no deploy.
938
+
939
+ ```tsx
940
+ import { Reviews } from '@reblu/site-blocks'
941
+
942
+ export async function HomeReviews() {
943
+ const { data: reviews } = await client.reviews
944
+ .list({ limit: 6 }, { revalidate: 3600 })
945
+ .catch(() => ({ data: [] }))
946
+ return (
947
+ <Reviews
948
+ reviews={reviews}
949
+ heading='Reseñas verificadas'
950
+ notice='Reseñas publicadas en Google y Doctoralia; cada una enlaza a su original.'
951
+ />
952
+ )
953
+ }
954
+
955
+ // Only the reviews linked to one page (a treatment, a professional's profile):
956
+ const { data: pageReviews } = await client.reviews.list({ pageId: page.id })
957
+ ```
958
+
959
+ - **Verifiable or not shown.** A review renders only with an `https://` link to
960
+ its original, a rating of 1 to 5 and an author; anything else is skipped.
961
+ The link opens in a new tab with `rel="noopener nofollow"` and a visually
962
+ hidden «(se abre en una pestaña nueva)» (`newWindowLabel`). Its text is
963
+ `linkLabel(sourceName)`, «Ver en Google» by default.
964
+ - **Notice.** `notice` is the tenant's short text on how its reviews are
965
+ checked. Spanish consumer law asks a business that shows reviews to say
966
+ whether and how it ensures they come from real customers; the exact wording
967
+ is the tenant's (pending legal review, REB-136). It goes below the grid, or
968
+ above it with `noticePosition='above'`.
969
+ - Stars are one `role="img"` labelled «5 de 5 estrellas». A star-only review
970
+ (`text: null`) renders without a quote. The date is the calendar day the
971
+ review was written, formatted in `locale` (default `es-ES`), never shifted
972
+ by the visitor's time zone.
973
+ - `null`, `undefined` or an empty list render nothing — no heading, no notice.
974
+ - `heading` wraps everything in a `<section aria-labelledby={headingId}>`
975
+ (default id `reviews-heading`, level `headingLevel`, default 2).
976
+ - Styling hooks: `className`, `cardClassName` (merge with `cn`);
977
+ `headingClassName`, `gridClassName` and `noticeClassName` follow the REB-997
978
+ slot rule and REPLACE their defaults (`text-xl font-semibold`,
979
+ `sm:grid-cols-2 lg:grid-cols-3`, `text-sm text-muted-foreground`).
980
+
901
981
  ## Trust badges
902
982
 
903
983
  `TrustBadges` renders the tenant's trust badges — partners, insurers, funding,
@@ -1382,6 +1462,53 @@ const course = await client.academy.bySlug(slug)
1382
1462
 
1383
1463
  The waiver item keeps `data-slot='course-purchase-terms-withdrawal-waiver'` (as in REB-1199); the express request to start uses `data-slot='course-purchase-terms-service-start-request'`. Both carry `data-kind` with the kind.
1384
1464
 
1465
+ ## Guest course checkout — `CourseGuestCheckoutForm` (REB-1264)
1466
+
1467
+ A course that needs no validation is bought in one step by a visitor without
1468
+ a session: name, surname, email, the tenant's terms box, the course's consent
1469
+ box (`withdrawalConsent`, required on the `course` prop — `null` when there
1470
+ is none — and its `version` is sent), the optional full invoice
1471
+ (fields hidden until the buyer opts in, or required over the cap), the total
1472
+ and the pay notice. On success the buyer goes to Stripe; the account is
1473
+ created after payment. On `ENROLLMENT_REQUEST_REQUIRED` the form gives way to
1474
+ `requestForm`. It says the same for every email and never mentions an account.
1475
+
1476
+ ```tsx
1477
+ // Server Component — the course page
1478
+ import { courseSellsAsGuest } from '@reblu/site-blocks'
1479
+ import { CourseGuestCheckoutForm } from '@reblu/site-blocks/client'
1480
+ import { AcademyEnrollmentForm } from '@reblu/site-next/academy'
1481
+
1482
+ const [course, shop] = await Promise.all([client.academy.bySlug(slug), client.shop.config()])
1483
+ const requestForm = (
1484
+ <AcademyEnrollmentForm enrollmentRequestUrl='/api/academy/enrollment-request' courseSlug={course.slug} />
1485
+ )
1486
+
1487
+ return courseSellsAsGuest(course) ? (
1488
+ <CourseGuestCheckoutForm
1489
+ // `withdrawalConsent` is required: `null` = the course has no box
1490
+ course={{ ...course, withdrawalConsent: course.withdrawalConsent ?? null }}
1491
+ copy={shop.checkout} // terms box, «Continuar al pago», pay notice, invoice texts
1492
+ termsHref='/condiciones'
1493
+ simplifiedInvoiceCapCents={shop.simplifiedInvoiceCapCents}
1494
+ issuesInvoices={shop.simplifiedInvoiceCapCents != null}
1495
+ requestForm={requestForm}
1496
+ />
1497
+ ) : (
1498
+ requestForm
1499
+ )
1500
+ ```
1501
+
1502
+ It posts to the SITE's own `/api/academy/checkout` (`endpoint`) — mount
1503
+ `createAcademyCheckoutRoute()` from `@reblu/site-next/route-handlers` there —
1504
+ or pass `submit` to send it yourself (`client.academy.checkout`). `{total}`
1505
+ and `{tenant}` in `copy.payNotice` are filled with the course price. Every
1506
+ refusal code has a default message (`DEFAULT_COURSE_GUEST_CHECKOUT_ERROR_MESSAGES`,
1507
+ override with `errorMessages`); `INVALID_BILLING` field errors land on their
1508
+ invoice field; `onError` gets each refusal (e.g. reload on
1509
+ `TERMS_VERSION_CHANGED` / `CONSENT_VERSION_CHANGED`). `toAcademyCheckoutInvoiceFields`
1510
+ (root entry) builds the `billing` block for a SITE with its own form.
1511
+
1385
1512
  ## Page blocks
1386
1513
 
1387
1514
  `PageBlocks` renders a page's registry blocks (`PageDetail.blocks` from
@@ -1418,6 +1545,9 @@ that applies the parent section's mirrored fields (REB-849):
1418
1545
  luminance; any other value keeps the theme foreground.
1419
1546
  - **`cta`** — rendered after the body as a link styled with `buttonVariants`;
1420
1547
  external CTAs open in a new tab with `rel="noopener noreferrer"`.
1548
+ - **`data.kicker`** — on the content blocks below (REB-1265..1268), a short
1549
+ line rendered above the heading (`data-slot="page-block-kicker"`). Only
1550
+ those block types render it; every other block's markup is unchanged.
1421
1551
 
1422
1552
  | Prop | Default | Purpose |
1423
1553
  | ------------------- | -------------------- | ---------------------------------------------------------- |
@@ -1458,9 +1588,110 @@ function TeamCard({ block, headingLevel, embedHref }: BlockRendererProps<'profil
1458
1588
  <PageBlocks blocks={page.blocks} components={{ profile_embed: TeamCard }} />
1459
1589
  ```
1460
1590
 
1461
- Every renderer receives `{ block, headingLevel, embedHref }`. Overrides may be
1591
+ Every renderer receives `{ block, headingLevel, embedHref }` (plus
1592
+ `headingId` for `image_text`, which draws the section heading itself). Overrides may be
1462
1593
  async Server Components, or Client Components when they need interactivity.
1463
1594
 
1595
+ ### Content blocks (REB-1265..1268)
1596
+
1597
+ Drop-in components for the content blocks a tenant builds in the nexus page
1598
+ editor. `PageBlocks` uses each one as the default renderer of its block type;
1599
+ you can also place them on any page yourself, passing the block's `data`.
1600
+ All are Server Components, take colours from theme tokens only, and lazy-load
1601
+ every image. The platform ships no tenant copy: the only built-in strings are
1602
+ neutral labels you can override.
1603
+
1604
+ #### `BeforeAfterPairs` — block `before_after` («Antes y después»)
1605
+
1606
+ Before/after image pairs. Each pair stacks on mobile and sits side by side from
1607
+ `sm`; pairs flow two per row from `lg`. The text under the pairs is opt-in: it
1608
+ renders **only** when `showCaption` is `true` **and** `caption` is not blank.
1609
+
1610
+ ```tsx
1611
+ import { BeforeAfterPairs } from '@reblu/site-blocks'
1612
+
1613
+ // `block.data` of a `before_after` block from `page.blocks`
1614
+ <BeforeAfterPairs
1615
+ pairs={block.data.pairs}
1616
+ showCaption={block.data.showCaption}
1617
+ caption={block.data.caption}
1618
+ labels={{ before: 'Before', after: 'After' }} // default: DEFAULT_BEFORE_AFTER_LABELS
1619
+ />
1620
+ ```
1621
+
1622
+ | Prop | Default | Purpose |
1623
+ | ------------- | ----------------------------- | -------------------------------------------------------- |
1624
+ | `pairs` | — | `{ beforeUrl, afterUrl, alt? }[]`. Empty with no caption renders nothing. |
1625
+ | `showCaption` | `false` | The tenant's «Mostrar texto al pie» toggle. |
1626
+ | `caption` | — | Free tenant text (legal note, consent…), line breaks kept. |
1627
+ | `labels` | `DEFAULT_BEFORE_AFTER_LABELS` | `{ before, after }` shown under each image. |
1628
+
1629
+ #### `ChipList` — block `chips` («Etiquetas»)
1630
+
1631
+ A short list of texts shown as chips. They wrap onto new lines on small
1632
+ screens and use the `primary` theme token (tinted background, primary text).
1633
+ Blank items are skipped; an empty list renders nothing.
1634
+
1635
+ ```tsx
1636
+ import { ChipList } from '@reblu/site-blocks'
1637
+
1638
+ <ChipList items={block.data.items} />
1639
+ ```
1640
+
1641
+ #### `IconCardGrid` — block `icon_cards` («Tarjetas con icono»)
1642
+
1643
+ Cards with an icon, a title and a short text, in a responsive grid: one column
1644
+ on mobile, two from `sm`, three from `lg`. The icon is a name from a fixed,
1645
+ curated set (`CARD_ICON_NAMES`, the `CardIcon` type) — tenants pick it in nexus,
1646
+ never upload one. The SVGs are inlined (Lucide geometry, ISC) and drawn in
1647
+ `currentColor` on a `primary`-tinted tile. An icon name this version does not
1648
+ know renders the card without an icon.
1649
+
1650
+ ```tsx
1651
+ import { IconCardGrid, CardIconSvg } from '@reblu/site-blocks'
1652
+
1653
+ <IconCardGrid intro={block.data.intro} items={block.data.items} headingLevel={3} />
1654
+
1655
+ // One icon on its own, e.g. in a custom card:
1656
+ <CardIconSvg icon='shield-check' className='h-6 w-6 text-primary' />
1657
+ ```
1658
+
1659
+ | Prop | Default | Purpose |
1660
+ | -------------- | ------- | ------------------------------------------------ |
1661
+ | `items` | — | `{ icon, title, text? }[]` — `data.items`. |
1662
+ | `intro` | — | `data.intro`, a line above the grid. |
1663
+ | `headingLevel` | `3` | Level of each card title. |
1664
+
1665
+ `feature_list` is unchanged: it still renders its plain list.
1666
+
1667
+ #### `ImageTextSplit` — block `image_text` («Imagen y texto»)
1668
+
1669
+ An image beside rich text. Stacked on mobile — the image first, or the text
1670
+ first with `mobileOrder='text_first'` — and side by side from `md`, with the
1671
+ image on `imageSide` (`'left'` or `'right'`). The kicker and the section
1672
+ heading sit at the top of the text column, so in `PageBlocks` this block draws
1673
+ the section heading itself: its renderer receives `headingId` (the
1674
+ `aria-labelledby` target) and the section's own `headingLevel`. `html` is the
1675
+ section's rich text, derived by base-api on save; it is sanitised here and its
1676
+ headings nest below the heading.
1677
+
1678
+ ```tsx
1679
+ import { ImageTextSplit } from '@reblu/site-blocks'
1680
+
1681
+ <ImageTextSplit
1682
+ imageUrl={block.data.imageUrl}
1683
+ alt={block.data.alt}
1684
+ imageSide={block.data.imageSide}
1685
+ mobileOrder={block.data.mobileOrder}
1686
+ html={block.data.html}
1687
+ kicker={block.data.kicker}
1688
+ heading={block.heading}
1689
+ headingLevel={2}
1690
+ />
1691
+ ```
1692
+
1693
+ The legacy `sections[]` layouts `TEXT_IMAGE` / `IMAGE_TEXT` are unchanged.
1694
+
1464
1695
  ### `FaqAccordion`
1465
1696
 
1466
1697
  Native `<details>`/`<summary>` — opens and closes with no JavaScript, keyboard