@reblu/site-blocks 0.30.0 → 0.31.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,30 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.31.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9a83295: Guest course checkout (REB-1264). Additive only: every existing export behaves as before.
8
+ - `@reblu/site-contracts` exports `AcademyCheckoutRequest`, `AcademyCheckoutBilling`, `AcademyCheckoutSession` and `AcademyCheckoutErrorCode` (`POST /api/site/academy/checkout`).
9
+ - `@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`.
10
+ - `@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.
11
+ - `@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`.
12
+
13
+ - 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.
14
+ - 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.
15
+ - 49f8f4b: New page content blocks (REB-1265..1268). Additive only: every existing block type renders exactly as before.
16
+ - `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`.
17
+ - `chips` («Etiquetas»): a short list of texts shown as chips. Drop-in `ChipList`; type `ChipsData`.
18
+ - `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.
19
+ - `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.
20
+ - `PageBlocks` renders `data.kicker` above the section heading for the new content blocks only.
21
+
22
+ ### Patch Changes
23
+
24
+ - Updated dependencies [9a83295]
25
+ - Updated dependencies [49f8f4b]
26
+ - @reblu/site-contracts@1.29.0
27
+
3
28
  ## 0.30.0
4
29
 
5
30
  ### Minor Changes
package/README.md CHANGED
@@ -571,7 +571,7 @@ directly — `loading`/`paid` are drop-in aliases for the original
571
571
  | `status` | Default copy | Order details shown | Extra UI |
572
572
  | --------------------- | ----------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------- |
573
573
  | `loading` / `polling` | «Confirmando tu pedido…» | — | — |
574
- | `paid` / `found` | «¡Pedido confirmado!» | Full summary (items, total, confirmation-email line) | — |
574
+ | `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
575
  | `failed` | «El pago no se ha completado» | None | "Volver al carrito" link when `cartHref` is given |
576
576
  | `refunded` | «Este pedido ha sido reembolsado» | Order number only (neutral reference) | — |
577
577
  | `partially_refunded` | «…reembolsado parcialmente» | Order number only | — |
@@ -591,6 +591,18 @@ reference (`labels.orderReferenceLabel`); `failed`/`not_found`/`error`
591
591
  render no order details at all, even if an `order` object happens to be
592
592
  present.
593
593
 
594
+ **REB-1200 — default copy change.** The confirmed line used to read «Te
595
+ hemos enviado un email con tu acceso.» for every order, physical ones too, and
596
+ buyers who already had an account got no new access. The default
597
+ `foundDescription` is now «Tu pedido {number} está confirmado. Te hemos
598
+ enviado el resumen a {email}.» (`{number}` = `order.orderNumber`, `{email}` =
599
+ `order.customerEmail`). Because it names the email, the separate «Te hemos
600
+ enviado un email de confirmación a…» line is not shown under it. With no order
601
+ loaded yet, `foundWithoutOrderDescription` («Tu pedido está confirmado.») is
602
+ shown instead of a raw placeholder. A SITE that passes its own
603
+ `labels.foundDescription` is unaffected: a text without `{email}` keeps the
604
+ confirmation-email line, exactly as before.
605
+
594
606
  Every title/description is overridable via `labels` (see
595
607
  `OrderConfirmationLabels`). See `@reblu/site-next`'s README
596
608
  "`useShopOrderStatus` — the order-confirmation flow" for the full wiring
@@ -627,6 +639,7 @@ configuration edited in nexus (`ShopConfig.delivery`, `ShopConfig.checkout`,
627
639
  | Block | Reads | Renders |
628
640
  | ----- | ----- | ------- |
629
641
  | `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. |
642
+ | `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
643
  | `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
644
  | `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
645
  | `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 +657,14 @@ import type { ShopConfig } from '@reblu/site-contracts'
644
657
  import {
645
658
  CheckoutDeliveryChoice,
646
659
  CheckoutPayAction,
660
+ CheckoutQuoteNotice,
647
661
  CheckoutQuoteSummary,
648
662
  CheckoutTermsAcceptance,
649
663
  EMPTY_CHECKOUT_ADDRESS,
650
664
  defaultCheckoutFulfillment,
665
+ checkoutQuoteNotice,
651
666
  getShopErrorMessage,
667
+ isCheckoutDeliveryComplete,
652
668
  quoteRequiresDelivery,
653
669
  toCheckoutFulfillmentFields,
654
670
  toCheckoutTermsFields,
@@ -660,9 +676,13 @@ export function SingleStepCheckout({ config }: { config: ShopConfig }) {
660
676
  const [address, setAddress] = useState(EMPTY_CHECKOUT_ADDRESS)
661
677
  const [acceptsTerms, setAcceptsTerms] = useState(false)
662
678
  const lines = items.map(({ productId, variantId, quantity }) => ({ productId, variantId, quantity }))
679
+ // Pickup with no point is known to be incomplete: ask no quote for it (REB-1276).
663
680
  const { quote, status, error, refresh } = useShopQuote(
664
- lines.length ? { items: lines, fulfillment: toCheckoutFulfillmentFields(fulfillment, address).fulfillment } : null,
681
+ lines.length && isCheckoutDeliveryComplete(fulfillment)
682
+ ? { items: lines, fulfillment: toCheckoutFulfillmentFields(fulfillment, address).fulfillment }
683
+ : null,
665
684
  )
685
+ const notice = checkoutQuoteNotice({ fulfillment, requiresDelivery: quoteRequiresDelivery(quote), error })
666
686
  // A cart that ships nothing hides the choice: send no address (blank = 400).
667
687
  const delivery = toCheckoutFulfillmentFields(fulfillment, address, quoteRequiresDelivery(quote))
668
688
 
@@ -704,8 +724,9 @@ export function SingleStepCheckout({ config }: { config: ShopConfig }) {
704
724
  ) : null}
705
725
  </div>
706
726
  <aside className='space-y-5'>
707
- {quote ? <CheckoutQuoteSummary quote={quote} heading='Resumen' /> : null}
708
- {status === 'error' ? (
727
+ {notice ? <CheckoutQuoteNotice reason={notice} /> : null}
728
+ {!notice && quote ? <CheckoutQuoteSummary quote={quote} heading='Resumen' /> : null}
729
+ {!notice && status === 'error' ? (
709
730
  <p role='alert'>
710
731
  {error?.message ?? 'No hemos podido calcular el total.'}{' '}
711
732
  <button type='button' onClick={() => void refresh()}>Reintentar</button>
@@ -751,6 +772,14 @@ Notes:
751
772
  of the hidden block (the server refuses a blank address with 400): the server records no delivery
752
773
  for such a cart, and the request stays on the current contract (REB-1227
753
774
  removes the old shape).
775
+ - Pickup point (REB-1276): switching to pickup with exactly one point selects
776
+ it (`pickupLocationId` is sent); with two or more, none is preselected.
777
+ While pickup has no point, `isCheckoutDeliveryComplete(fulfillment)` is
778
+ `false`: pass `null` to `useShopQuote` (no request that is known to answer
779
+ 422) and render `CheckoutQuoteNotice` with `checkoutQuoteNotice(...)`. The
780
+ same notice covers a `PICKUP_LOCATION_INVALID` quote error (a point
781
+ deactivated meanwhile), never the generic total error. Replace its text with
782
+ `labels={{ pickupLocationRequired: '…' }}`.
754
783
  - The full-invoice texts are the tenant's too: pass
755
784
  `labels={{ toggle: config.checkout.fullInvoiceCheckboxLabel, underCapNotice: config.checkout.fullInvoiceHelpText }}`
756
785
  to `FullInvoiceFields`.
@@ -1382,6 +1411,53 @@ const course = await client.academy.bySlug(slug)
1382
1411
 
1383
1412
  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
1413
 
1414
+ ## Guest course checkout — `CourseGuestCheckoutForm` (REB-1264)
1415
+
1416
+ A course that needs no validation is bought in one step by a visitor without
1417
+ a session: name, surname, email, the tenant's terms box, the course's consent
1418
+ box (`withdrawalConsent`, required on the `course` prop — `null` when there
1419
+ is none — and its `version` is sent), the optional full invoice
1420
+ (fields hidden until the buyer opts in, or required over the cap), the total
1421
+ and the pay notice. On success the buyer goes to Stripe; the account is
1422
+ created after payment. On `ENROLLMENT_REQUEST_REQUIRED` the form gives way to
1423
+ `requestForm`. It says the same for every email and never mentions an account.
1424
+
1425
+ ```tsx
1426
+ // Server Component — the course page
1427
+ import { courseSellsAsGuest } from '@reblu/site-blocks'
1428
+ import { CourseGuestCheckoutForm } from '@reblu/site-blocks/client'
1429
+ import { AcademyEnrollmentForm } from '@reblu/site-next/academy'
1430
+
1431
+ const [course, shop] = await Promise.all([client.academy.bySlug(slug), client.shop.config()])
1432
+ const requestForm = (
1433
+ <AcademyEnrollmentForm enrollmentRequestUrl='/api/academy/enrollment-request' courseSlug={course.slug} />
1434
+ )
1435
+
1436
+ return courseSellsAsGuest(course) ? (
1437
+ <CourseGuestCheckoutForm
1438
+ // `withdrawalConsent` is required: `null` = the course has no box
1439
+ course={{ ...course, withdrawalConsent: course.withdrawalConsent ?? null }}
1440
+ copy={shop.checkout} // terms box, «Continuar al pago», pay notice, invoice texts
1441
+ termsHref='/condiciones'
1442
+ simplifiedInvoiceCapCents={shop.simplifiedInvoiceCapCents}
1443
+ issuesInvoices={shop.simplifiedInvoiceCapCents != null}
1444
+ requestForm={requestForm}
1445
+ />
1446
+ ) : (
1447
+ requestForm
1448
+ )
1449
+ ```
1450
+
1451
+ It posts to the SITE's own `/api/academy/checkout` (`endpoint`) — mount
1452
+ `createAcademyCheckoutRoute()` from `@reblu/site-next/route-handlers` there —
1453
+ or pass `submit` to send it yourself (`client.academy.checkout`). `{total}`
1454
+ and `{tenant}` in `copy.payNotice` are filled with the course price. Every
1455
+ refusal code has a default message (`DEFAULT_COURSE_GUEST_CHECKOUT_ERROR_MESSAGES`,
1456
+ override with `errorMessages`); `INVALID_BILLING` field errors land on their
1457
+ invoice field; `onError` gets each refusal (e.g. reload on
1458
+ `TERMS_VERSION_CHANGED` / `CONSENT_VERSION_CHANGED`). `toAcademyCheckoutInvoiceFields`
1459
+ (root entry) builds the `billing` block for a SITE with its own form.
1460
+
1385
1461
  ## Page blocks
1386
1462
 
1387
1463
  `PageBlocks` renders a page's registry blocks (`PageDetail.blocks` from
@@ -1418,6 +1494,9 @@ that applies the parent section's mirrored fields (REB-849):
1418
1494
  luminance; any other value keeps the theme foreground.
1419
1495
  - **`cta`** — rendered after the body as a link styled with `buttonVariants`;
1420
1496
  external CTAs open in a new tab with `rel="noopener noreferrer"`.
1497
+ - **`data.kicker`** — on the content blocks below (REB-1265..1268), a short
1498
+ line rendered above the heading (`data-slot="page-block-kicker"`). Only
1499
+ those block types render it; every other block's markup is unchanged.
1421
1500
 
1422
1501
  | Prop | Default | Purpose |
1423
1502
  | ------------------- | -------------------- | ---------------------------------------------------------- |
@@ -1458,9 +1537,110 @@ function TeamCard({ block, headingLevel, embedHref }: BlockRendererProps<'profil
1458
1537
  <PageBlocks blocks={page.blocks} components={{ profile_embed: TeamCard }} />
1459
1538
  ```
1460
1539
 
1461
- Every renderer receives `{ block, headingLevel, embedHref }`. Overrides may be
1540
+ Every renderer receives `{ block, headingLevel, embedHref }` (plus
1541
+ `headingId` for `image_text`, which draws the section heading itself). Overrides may be
1462
1542
  async Server Components, or Client Components when they need interactivity.
1463
1543
 
1544
+ ### Content blocks (REB-1265..1268)
1545
+
1546
+ Drop-in components for the content blocks a tenant builds in the nexus page
1547
+ editor. `PageBlocks` uses each one as the default renderer of its block type;
1548
+ you can also place them on any page yourself, passing the block's `data`.
1549
+ All are Server Components, take colours from theme tokens only, and lazy-load
1550
+ every image. The platform ships no tenant copy: the only built-in strings are
1551
+ neutral labels you can override.
1552
+
1553
+ #### `BeforeAfterPairs` — block `before_after` («Antes y después»)
1554
+
1555
+ Before/after image pairs. Each pair stacks on mobile and sits side by side from
1556
+ `sm`; pairs flow two per row from `lg`. The text under the pairs is opt-in: it
1557
+ renders **only** when `showCaption` is `true` **and** `caption` is not blank.
1558
+
1559
+ ```tsx
1560
+ import { BeforeAfterPairs } from '@reblu/site-blocks'
1561
+
1562
+ // `block.data` of a `before_after` block from `page.blocks`
1563
+ <BeforeAfterPairs
1564
+ pairs={block.data.pairs}
1565
+ showCaption={block.data.showCaption}
1566
+ caption={block.data.caption}
1567
+ labels={{ before: 'Before', after: 'After' }} // default: DEFAULT_BEFORE_AFTER_LABELS
1568
+ />
1569
+ ```
1570
+
1571
+ | Prop | Default | Purpose |
1572
+ | ------------- | ----------------------------- | -------------------------------------------------------- |
1573
+ | `pairs` | — | `{ beforeUrl, afterUrl, alt? }[]`. Empty with no caption renders nothing. |
1574
+ | `showCaption` | `false` | The tenant's «Mostrar texto al pie» toggle. |
1575
+ | `caption` | — | Free tenant text (legal note, consent…), line breaks kept. |
1576
+ | `labels` | `DEFAULT_BEFORE_AFTER_LABELS` | `{ before, after }` shown under each image. |
1577
+
1578
+ #### `ChipList` — block `chips` («Etiquetas»)
1579
+
1580
+ A short list of texts shown as chips. They wrap onto new lines on small
1581
+ screens and use the `primary` theme token (tinted background, primary text).
1582
+ Blank items are skipped; an empty list renders nothing.
1583
+
1584
+ ```tsx
1585
+ import { ChipList } from '@reblu/site-blocks'
1586
+
1587
+ <ChipList items={block.data.items} />
1588
+ ```
1589
+
1590
+ #### `IconCardGrid` — block `icon_cards` («Tarjetas con icono»)
1591
+
1592
+ Cards with an icon, a title and a short text, in a responsive grid: one column
1593
+ on mobile, two from `sm`, three from `lg`. The icon is a name from a fixed,
1594
+ curated set (`CARD_ICON_NAMES`, the `CardIcon` type) — tenants pick it in nexus,
1595
+ never upload one. The SVGs are inlined (Lucide geometry, ISC) and drawn in
1596
+ `currentColor` on a `primary`-tinted tile. An icon name this version does not
1597
+ know renders the card without an icon.
1598
+
1599
+ ```tsx
1600
+ import { IconCardGrid, CardIconSvg } from '@reblu/site-blocks'
1601
+
1602
+ <IconCardGrid intro={block.data.intro} items={block.data.items} headingLevel={3} />
1603
+
1604
+ // One icon on its own, e.g. in a custom card:
1605
+ <CardIconSvg icon='shield-check' className='h-6 w-6 text-primary' />
1606
+ ```
1607
+
1608
+ | Prop | Default | Purpose |
1609
+ | -------------- | ------- | ------------------------------------------------ |
1610
+ | `items` | — | `{ icon, title, text? }[]` — `data.items`. |
1611
+ | `intro` | — | `data.intro`, a line above the grid. |
1612
+ | `headingLevel` | `3` | Level of each card title. |
1613
+
1614
+ `feature_list` is unchanged: it still renders its plain list.
1615
+
1616
+ #### `ImageTextSplit` — block `image_text` («Imagen y texto»)
1617
+
1618
+ An image beside rich text. Stacked on mobile — the image first, or the text
1619
+ first with `mobileOrder='text_first'` — and side by side from `md`, with the
1620
+ image on `imageSide` (`'left'` or `'right'`). The kicker and the section
1621
+ heading sit at the top of the text column, so in `PageBlocks` this block draws
1622
+ the section heading itself: its renderer receives `headingId` (the
1623
+ `aria-labelledby` target) and the section's own `headingLevel`. `html` is the
1624
+ section's rich text, derived by base-api on save; it is sanitised here and its
1625
+ headings nest below the heading.
1626
+
1627
+ ```tsx
1628
+ import { ImageTextSplit } from '@reblu/site-blocks'
1629
+
1630
+ <ImageTextSplit
1631
+ imageUrl={block.data.imageUrl}
1632
+ alt={block.data.alt}
1633
+ imageSide={block.data.imageSide}
1634
+ mobileOrder={block.data.mobileOrder}
1635
+ html={block.data.html}
1636
+ kicker={block.data.kicker}
1637
+ heading={block.heading}
1638
+ headingLevel={2}
1639
+ />
1640
+ ```
1641
+
1642
+ The legacy `sections[]` layouts `TEXT_IMAGE` / `IMAGE_TEXT` are unchanged.
1643
+
1464
1644
  ### `FaqAccordion`
1465
1645
 
1466
1646
  Native `<details>`/`<summary>` — opens and closes with no JavaScript, keyboard