@reblu/site-blocks 0.27.0 → 0.29.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,22 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.29.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cdab121: Single-step checkout drop-ins (REB-1197 / REB-1198), all rendering tenant configuration from the site API: `CheckoutDeliveryChoice` (home delivery with the address form, cost and estimate, or free pickup at the tenant's points), `CheckoutQuoteSummary` (lines, subtotal, discount, delivery, tax rows, total from `ShopQuote`, plus why a code was not applied or why the order cannot be paid), `CheckoutTermsAcceptance` (the required, unticked terms box with the link to the full document) and `CheckoutPayAction` («Continuar al pago» with the amount notice under it). Body helpers: `defaultCheckoutFulfillment`, `quoteRequiresDelivery`, `toCheckoutFulfillmentFields(value, address, requiresDelivery?)` (no address for a cart that ships nothing), `toCheckoutTermsFields`.
8
+
9
+ ## 0.28.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 275adaf: REB-1201: `ArbitrationBadge` drop-in renders the tenant's consumer-arbitration distinctive in the footer — logo at `h-16`, `alt` with the adhesion number («… N.º de adhesión CA00000»), the number as visible text, optional link in a new window. `TrustBadges` knows the new type (heading «Arbitraje de consumo») but leaves it out of its default groups, so a footer with both never shows it twice; opt in with `types`. Any badge with an adhesion number gets it in its `alt`.
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [275adaf]
18
+ - @reblu/site-contracts@1.28.0
19
+
3
20
  ## 0.27.0
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -35,6 +35,7 @@ colour on the red background. See `docs/theming/theming.md`.
35
35
  | `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
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
+ | `ArbitrationBadge` | `.` | The consumer-arbitration distinctive with its adhesion number, for the footer. Server Component. See "Consumer arbitration badge" below. |
38
39
  | `buildFaqPageJsonLd`, `collectFaqItems` | `./seo` | schema.org `FAQPage` structured data. Directive-free (also on `.`). See "Page blocks" below. |
39
40
  | `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
40
41
  | `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
@@ -584,6 +585,148 @@ import { OrderInvoiceLink } from '@reblu/site-blocks'
584
585
  // «Descargar la factura FAC-2026-0010» + «Te pediremos que inicies sesión para abrirla.»
585
586
  ```
586
587
 
588
+ ### The single-step checkout (REB-1197 / REB-1198)
589
+
590
+ One last web step before Stripe: what is being bought, how it is delivered,
591
+ the contact data, the summary with shipping and taxes, the terms box and
592
+ «Continuar al pago» with the amount under it. Every text and rule is tenant
593
+ configuration edited in nexus (`ShopConfig.delivery`, `ShopConfig.checkout`,
594
+ `ShopQuote`); the blocks render what the API returns and carry no tenant copy.
595
+
596
+ | Block | Reads | Renders |
597
+ | ----- | ----- | ------- |
598
+ | `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. |
599
+ | `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. |
600
+ | `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. |
601
+ | `CheckoutPayAction` | `config.checkout.continueToPaymentLabel`, `quote.payNotice` | The submit button and the notice with the amount under it (`text-sm`, the button's description). |
602
+
603
+ Mount `createShopQuoteRoute()` at `app/api/shop/quote/route.ts` and
604
+ `createShopCheckoutRoute()` at `app/api/shop/checkout/route.ts`
605
+ (`@reblu/site-next/shop-route-handlers`), then:
606
+
607
+ ```tsx
608
+ 'use client'
609
+ import { useState } from 'react'
610
+ import { useCart } from '@reblu/site-next/cart'
611
+ import { useShopQuote } from '@reblu/site-next/shop'
612
+ import type { ShopConfig } from '@reblu/site-contracts'
613
+ import {
614
+ CheckoutDeliveryChoice,
615
+ CheckoutPayAction,
616
+ CheckoutQuoteSummary,
617
+ CheckoutTermsAcceptance,
618
+ EMPTY_CHECKOUT_ADDRESS,
619
+ defaultCheckoutFulfillment,
620
+ getShopErrorMessage,
621
+ quoteRequiresDelivery,
622
+ toCheckoutFulfillmentFields,
623
+ toCheckoutTermsFields,
624
+ } from '@reblu/site-blocks'
625
+
626
+ export function SingleStepCheckout({ config }: { config: ShopConfig }) {
627
+ const { items } = useCart()
628
+ const [fulfillment, setFulfillment] = useState(() => defaultCheckoutFulfillment(config.delivery))
629
+ const [address, setAddress] = useState(EMPTY_CHECKOUT_ADDRESS)
630
+ const [acceptsTerms, setAcceptsTerms] = useState(false)
631
+ const lines = items.map(({ productId, variantId, quantity }) => ({ productId, variantId, quantity }))
632
+ const { quote, status, error, refresh } = useShopQuote(
633
+ lines.length ? { items: lines, fulfillment: toCheckoutFulfillmentFields(fulfillment, address).fulfillment } : null,
634
+ )
635
+ // A cart that ships nothing hides the choice: send no address (blank = 400).
636
+ const delivery = toCheckoutFulfillmentFields(fulfillment, address, quoteRequiresDelivery(quote))
637
+
638
+ async function onSubmit(event: React.FormEvent) {
639
+ event.preventDefault()
640
+ // Pay only against a total the buyer saw, and a fresh one.
641
+ const fresh = await refresh()
642
+ if (!fresh?.canCheckout) return
643
+ const res = await fetch('/api/shop/checkout', {
644
+ method: 'POST',
645
+ headers: { 'Content-Type': 'application/json' },
646
+ body: JSON.stringify({
647
+ items: lines,
648
+ customerName, customerEmail, // your contact fields (or CheckoutForm)
649
+ ...delivery,
650
+ ...toCheckoutTermsFields(config.checkout, acceptsTerms),
651
+ }),
652
+ })
653
+ const json = await res.json()
654
+ if (res.ok) window.location.assign(json.data.url)
655
+ else showError(getShopErrorMessage(json.code)) // TERMS_VERSION_CHANGED: reload the config
656
+ }
657
+
658
+ return (
659
+ <form onSubmit={onSubmit} className='grid gap-8 lg:grid-cols-3'>
660
+ <div className='space-y-8 lg:col-span-2'>
661
+ {/* contact fields, FullInvoiceFields, discount code… */}
662
+ {config.delivery && config.checkout ? (
663
+ <CheckoutDeliveryChoice
664
+ delivery={config.delivery}
665
+ copy={config.checkout}
666
+ value={fulfillment}
667
+ onChange={setFulfillment}
668
+ address={address}
669
+ onAddressChange={setAddress}
670
+ quote={quote}
671
+ requiresDelivery={quoteRequiresDelivery(quote)}
672
+ />
673
+ ) : null}
674
+ </div>
675
+ <aside className='space-y-5'>
676
+ {quote ? <CheckoutQuoteSummary quote={quote} heading='Resumen' /> : null}
677
+ {status === 'error' ? (
678
+ <p role='alert'>
679
+ {error?.message ?? 'No hemos podido calcular el total.'}{' '}
680
+ <button type='button' onClick={() => void refresh()}>Reintentar</button>
681
+ </p>
682
+ ) : null}
683
+ {config.checkout ? (
684
+ <>
685
+ <CheckoutTermsAcceptance
686
+ copy={config.checkout}
687
+ termsHref='/terms-and-conditions'
688
+ checked={acceptsTerms}
689
+ onCheckedChange={setAcceptsTerms}
690
+ />
691
+ <CheckoutPayAction
692
+ label={config.checkout.continueToPaymentLabel}
693
+ notice={quote?.payNotice}
694
+ // No quote (loading or error) = no total shown = no payment.
695
+ disabled={status !== 'ready' || !quote?.canCheckout}
696
+ />
697
+ </>
698
+ ) : null}
699
+ </aside>
700
+ </form>
701
+ )
702
+ }
703
+ ```
704
+
705
+ Notes:
706
+
707
+ - `quote.total` is exactly what Stripe charges for the same body: base-api
708
+ prices the quote and the checkout with one function. Show it, never a sum
709
+ of your own.
710
+ - Never let the buyer pay without a ready quote: while `useShopQuote` is
711
+ loading or failed, disable `CheckoutPayAction`, show the error with a retry
712
+ (`refresh()`), and `await refresh()` in `onSubmit` so the charged total is
713
+ the one on screen.
714
+ - Send the discount code in the quote input too. A code that cannot be used
715
+ comes back in `quote.discountError` and is left out of the total; drop it
716
+ from your state when `discountError.code === 'DISCOUNT_LIMIT_REACHED'`.
717
+ - `toCheckoutFulfillmentFields(value, address, quoteRequiresDelivery(quote))`:
718
+ for a cart with nothing to ship (third argument `false`, or no choice) it
719
+ sends only `fulfillment: { type: 'SHIPPING' }` and never the blank address
720
+ of the hidden block (the server refuses a blank address with 400): the server records no delivery
721
+ for such a cart, and the request stays on the current contract (REB-1227
722
+ removes the old shape).
723
+ - The full-invoice texts are the tenant's too: pass
724
+ `labels={{ toggle: config.checkout.fullInvoiceCheckboxLabel, underCapNotice: config.checkout.fullInvoiceHelpText }}`
725
+ to `FullInvoiceFields`.
726
+ - Ids default to `checkout-delivery`, `checkout-terms` and `checkout-pay`
727
+ (one checkout per page); pass `id` to change them. The three blocks hold no
728
+ state and use no hooks, so they ship from the RSC-safe root.
729
+
587
730
  ### The full-invoice request — `FullInvoiceFields` (REB-73)
588
731
 
589
732
  A full invoice needs the buyer's identifier — a Spanish NIF/NIE/CIF, or for a
@@ -755,7 +898,11 @@ export async function Footer() {
755
898
  - `types` picks which groups render and in what order, e.g. only the funding
756
899
  logos: `<TrustBadges badges={badges} types={['SUBVENTION']} />`. To fetch
757
900
  only one type, `client.trustBadges.list({ type: 'SUBVENTION' })`.
758
- - A logo is an `<img>` with `alt` = badge name, rendered only when its URL is
901
+ - `CONSUMER_ARBITRATION` is not in the default groups — render it with
902
+ `ArbitrationBadge` (below). Opt in with `types` to group it here; its heading
903
+ is «Arbitraje de consumo».
904
+ - A logo is an `<img>` with `alt` = badge name (plus «N.º de adhesión …» when
905
+ the badge has an adhesion number), rendered only when its URL is
759
906
  http(s) or root-relative (`/logos/x.svg`); any other scheme (`javascript:`,
760
907
  `data:`, `//host`) is dropped and the badge shows its name as text, like a
761
908
  badge without a logo. A badge `url` (http/https only) opens in a new window
@@ -781,6 +928,46 @@ export async function Footer() {
781
928
  `listClassName` and `logoClassName` are layout/sizing slots and still merge
782
929
  with `cn`.
783
930
 
931
+ ## Consumer arbitration badge — `ArbitrationBadge` (REB-1201)
932
+
933
+ A tenant adhered to the consumer arbitration system shows the official
934
+ distinctive in the footer, visible and accessible, with its adhesion number. In
935
+ nexus › Trust badges create a badge of type «Arbitraje de consumo» with the
936
+ official logo, the «Número de adhesión» (required for that type) and an
937
+ optional link. The API serves it like any other badge, with `adhesionNumber`.
938
+
939
+ ```tsx
940
+ import { ArbitrationBadge, TrustBadges } from '@reblu/site-blocks'
941
+
942
+ export async function Footer() {
943
+ const { data: badges } = await client.trustBadges.list().catch(() => ({ data: [] }))
944
+ return (
945
+ <footer>
946
+ {/* … */}
947
+ <TrustBadges badges={badges} headingLevel={2} />
948
+ <ArbitrationBadge badges={badges} />
949
+ </footer>
950
+ )
951
+ }
952
+ ```
953
+
954
+ - Feed it the whole list: only `CONSUMER_ARBITRATION` badges render, and
955
+ `TrustBadges` leaves that type out by default, so it never shows twice. To
956
+ fetch only it, `client.trustBadges.list({ type: 'CONSUMER_ARBITRATION' })`.
957
+ - The logo is an `<img>` at `h-16` (not a tiny icon) with `alt` = «{name}.
958
+ N.º de adhesión {number}». The number is also shown as visible text under the
959
+ logo, `aria-hidden` there because the `alt` already says it. Without a usable
960
+ logo (same http(s)/root-relative rule as `TrustBadges`) the name and number
961
+ are plain text.
962
+ - `numberLabel` changes «N.º de adhesión» (alt and caption); `showNumber={false}`
963
+ drops the visible caption, the `alt` keeps the number.
964
+ - A http(s) `url` opens in a new window with `rel="noopener noreferrer"` and a
965
+ visually hidden `newWindowLabel`.
966
+ - Nothing renders without a named consumer-arbitration badge.
967
+ - Styling hooks: `className` (the `<ul>`), `logoClassName` (merged with
968
+ `h-16 w-auto object-contain`, e.g. `brightness-0 invert` on a dark footer),
969
+ `numberClassName`.
970
+
784
971
  ## `ParentPage` (formerly `Specialty`)
785
972
 
786
973
  `ParentPage` renders a page that groups other pages: the hero and content