@reblu/site-blocks 0.28.0 → 0.30.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,17 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.30.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3b096d4: `CartLineList` gains an optional `lineIncludes` render prop (REB-1208): it adds a collapsed «Qué incluye» disclosure under a line (`labels.includesSummary`). New drop-in `CartLineIncludes` (term/description list plus optional `note`) and `bundleComponentsToIncludes`, which maps `ShopProductDetail.bundleComponents` to its entries. Omitting the prop renders the cart exactly as before.
8
+
9
+ ## 0.29.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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`.
14
+
3
15
  ## 0.28.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -327,7 +327,8 @@ for the wiring steps and a full example app).
327
327
  | `AddToCartButton` | `./client` | `onAddToCart?`, `disabled?`, `outOfStock?`, `pending?`, `error?`, `item?`, `successDurationMs?`, `onAdded?`, `onError?` | No — carries a built-in confirmed/error timer (`useState`/`useEffect`), see below |
328
328
  | `CartIcon` | `./client` | `itemCount`, `href`, `hydrated?`, `bumpOnAdd?` | No — the bump animation needs state, see below |
329
329
  | `AddedToCartToast` | `.` | `productName`, `cartHref`, `status?`, `quantity?`, `imageUrl?` | Yes |
330
- | `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?`, `lineMessage?` | Yes |
330
+ | `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?`, `lineMessage?`, `lineIncludes?` | Yes |
331
+ | `CartLineIncludes` | `.` | `entries`, `note?` | Yes |
331
332
  | `CartAdjustmentsNotice` | `.` | `removed?`, `quantityChanges?`, `priceChanges?`, `labels?` | Yes |
332
333
  | `CartSummary` | `.` | `subtotal`, `discount?`, `autoDiscount?`, `shipping?`, `shippingEnabled?`, `total`, `notice?` | Yes |
333
334
  | `CheckoutLoginPrompt` | `.` | `open`, `email`, `onEmailChange`, `onClose`, `onSubmitEmail`, `onContinueAsGuest` | Yes (renders `null` when `open` is false) |
@@ -530,6 +531,36 @@ function). `currencyCode`/`locale` format the prices.
530
531
  `CartLineList`'s `lineMessage={(item) => …}` renders a message under a line,
531
532
  e.g. `validation.getLineError(item.productId, item.variantId)?.message`.
532
533
 
534
+ ### «Qué incluye» per line — `lineIncludes` + `CartLineIncludes` (REB-1208)
535
+
536
+ `CartLineList`'s `lineIncludes={(item) => …}` adds a collapsed
537
+ `<details>` under a line whose summary reads «Qué incluye»
538
+ (`labels.includesSummary`). Return `null`/`undefined` for lines with nothing
539
+ to show; omit the prop and the cart renders exactly as before.
540
+
541
+ `CartLineIncludes` is the drop-in body: a term/description list plus an
542
+ optional `note`. Build its entries from the product's own data — nothing is
543
+ fixed per product type. For a bundle, `bundleComponentsToIncludes` maps
544
+ `ShopProductDetail.bundleComponents` to name / `x{quantity}` rows:
545
+
546
+ ```tsx
547
+ import { CartLineIncludes, CartLineList, bundleComponentsToIncludes } from '@reblu/site-blocks'
548
+
549
+ // `detailsById`: the SITE's ShopProductDetail per productId (already fetched for the ficha).
550
+ <CartLineList
551
+ items={items}
552
+ lineIncludes={(item) => {
553
+ const product = detailsById[item.productId]
554
+ if (!product) return null
555
+ const entries = [
556
+ ...bundleComponentsToIncludes(product.bundleComponents),
557
+ // …plus any other `{ label, value }` row built from the product's own data.
558
+ ]
559
+ return entries.length > 0 ? <CartLineIncludes entries={entries} /> : null
560
+ }}
561
+ />
562
+ ```
563
+
533
564
  ### `OrderConfirmation` states (REB-837)
534
565
 
535
566
  `status` accepts `@reblu/site-next/shop`'s `useShopOrderStatus` hook result
@@ -585,6 +616,148 @@ import { OrderInvoiceLink } from '@reblu/site-blocks'
585
616
  // «Descargar la factura FAC-2026-0010» + «Te pediremos que inicies sesión para abrirla.»
586
617
  ```
587
618
 
619
+ ### The single-step checkout (REB-1197 / REB-1198)
620
+
621
+ One last web step before Stripe: what is being bought, how it is delivered,
622
+ the contact data, the summary with shipping and taxes, the terms box and
623
+ «Continuar al pago» with the amount under it. Every text and rule is tenant
624
+ configuration edited in nexus (`ShopConfig.delivery`, `ShopConfig.checkout`,
625
+ `ShopQuote`); the blocks render what the API returns and carry no tenant copy.
626
+
627
+ | Block | Reads | Renders |
628
+ | ----- | ----- | ------- |
629
+ | `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. |
630
+ | `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
+ | `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
+ | `CheckoutPayAction` | `config.checkout.continueToPaymentLabel`, `quote.payNotice` | The submit button and the notice with the amount under it (`text-sm`, the button's description). |
633
+
634
+ Mount `createShopQuoteRoute()` at `app/api/shop/quote/route.ts` and
635
+ `createShopCheckoutRoute()` at `app/api/shop/checkout/route.ts`
636
+ (`@reblu/site-next/shop-route-handlers`), then:
637
+
638
+ ```tsx
639
+ 'use client'
640
+ import { useState } from 'react'
641
+ import { useCart } from '@reblu/site-next/cart'
642
+ import { useShopQuote } from '@reblu/site-next/shop'
643
+ import type { ShopConfig } from '@reblu/site-contracts'
644
+ import {
645
+ CheckoutDeliveryChoice,
646
+ CheckoutPayAction,
647
+ CheckoutQuoteSummary,
648
+ CheckoutTermsAcceptance,
649
+ EMPTY_CHECKOUT_ADDRESS,
650
+ defaultCheckoutFulfillment,
651
+ getShopErrorMessage,
652
+ quoteRequiresDelivery,
653
+ toCheckoutFulfillmentFields,
654
+ toCheckoutTermsFields,
655
+ } from '@reblu/site-blocks'
656
+
657
+ export function SingleStepCheckout({ config }: { config: ShopConfig }) {
658
+ const { items } = useCart()
659
+ const [fulfillment, setFulfillment] = useState(() => defaultCheckoutFulfillment(config.delivery))
660
+ const [address, setAddress] = useState(EMPTY_CHECKOUT_ADDRESS)
661
+ const [acceptsTerms, setAcceptsTerms] = useState(false)
662
+ const lines = items.map(({ productId, variantId, quantity }) => ({ productId, variantId, quantity }))
663
+ const { quote, status, error, refresh } = useShopQuote(
664
+ lines.length ? { items: lines, fulfillment: toCheckoutFulfillmentFields(fulfillment, address).fulfillment } : null,
665
+ )
666
+ // A cart that ships nothing hides the choice: send no address (blank = 400).
667
+ const delivery = toCheckoutFulfillmentFields(fulfillment, address, quoteRequiresDelivery(quote))
668
+
669
+ async function onSubmit(event: React.FormEvent) {
670
+ event.preventDefault()
671
+ // Pay only against a total the buyer saw, and a fresh one.
672
+ const fresh = await refresh()
673
+ if (!fresh?.canCheckout) return
674
+ const res = await fetch('/api/shop/checkout', {
675
+ method: 'POST',
676
+ headers: { 'Content-Type': 'application/json' },
677
+ body: JSON.stringify({
678
+ items: lines,
679
+ customerName, customerEmail, // your contact fields (or CheckoutForm)
680
+ ...delivery,
681
+ ...toCheckoutTermsFields(config.checkout, acceptsTerms),
682
+ }),
683
+ })
684
+ const json = await res.json()
685
+ if (res.ok) window.location.assign(json.data.url)
686
+ else showError(getShopErrorMessage(json.code)) // TERMS_VERSION_CHANGED: reload the config
687
+ }
688
+
689
+ return (
690
+ <form onSubmit={onSubmit} className='grid gap-8 lg:grid-cols-3'>
691
+ <div className='space-y-8 lg:col-span-2'>
692
+ {/* contact fields, FullInvoiceFields, discount code… */}
693
+ {config.delivery && config.checkout ? (
694
+ <CheckoutDeliveryChoice
695
+ delivery={config.delivery}
696
+ copy={config.checkout}
697
+ value={fulfillment}
698
+ onChange={setFulfillment}
699
+ address={address}
700
+ onAddressChange={setAddress}
701
+ quote={quote}
702
+ requiresDelivery={quoteRequiresDelivery(quote)}
703
+ />
704
+ ) : null}
705
+ </div>
706
+ <aside className='space-y-5'>
707
+ {quote ? <CheckoutQuoteSummary quote={quote} heading='Resumen' /> : null}
708
+ {status === 'error' ? (
709
+ <p role='alert'>
710
+ {error?.message ?? 'No hemos podido calcular el total.'}{' '}
711
+ <button type='button' onClick={() => void refresh()}>Reintentar</button>
712
+ </p>
713
+ ) : null}
714
+ {config.checkout ? (
715
+ <>
716
+ <CheckoutTermsAcceptance
717
+ copy={config.checkout}
718
+ termsHref='/terms-and-conditions'
719
+ checked={acceptsTerms}
720
+ onCheckedChange={setAcceptsTerms}
721
+ />
722
+ <CheckoutPayAction
723
+ label={config.checkout.continueToPaymentLabel}
724
+ notice={quote?.payNotice}
725
+ // No quote (loading or error) = no total shown = no payment.
726
+ disabled={status !== 'ready' || !quote?.canCheckout}
727
+ />
728
+ </>
729
+ ) : null}
730
+ </aside>
731
+ </form>
732
+ )
733
+ }
734
+ ```
735
+
736
+ Notes:
737
+
738
+ - `quote.total` is exactly what Stripe charges for the same body: base-api
739
+ prices the quote and the checkout with one function. Show it, never a sum
740
+ of your own.
741
+ - Never let the buyer pay without a ready quote: while `useShopQuote` is
742
+ loading or failed, disable `CheckoutPayAction`, show the error with a retry
743
+ (`refresh()`), and `await refresh()` in `onSubmit` so the charged total is
744
+ the one on screen.
745
+ - Send the discount code in the quote input too. A code that cannot be used
746
+ comes back in `quote.discountError` and is left out of the total; drop it
747
+ from your state when `discountError.code === 'DISCOUNT_LIMIT_REACHED'`.
748
+ - `toCheckoutFulfillmentFields(value, address, quoteRequiresDelivery(quote))`:
749
+ for a cart with nothing to ship (third argument `false`, or no choice) it
750
+ sends only `fulfillment: { type: 'SHIPPING' }` and never the blank address
751
+ of the hidden block (the server refuses a blank address with 400): the server records no delivery
752
+ for such a cart, and the request stays on the current contract (REB-1227
753
+ removes the old shape).
754
+ - The full-invoice texts are the tenant's too: pass
755
+ `labels={{ toggle: config.checkout.fullInvoiceCheckboxLabel, underCapNotice: config.checkout.fullInvoiceHelpText }}`
756
+ to `FullInvoiceFields`.
757
+ - Ids default to `checkout-delivery`, `checkout-terms` and `checkout-pay`
758
+ (one checkout per page); pass `id` to change them. The three blocks hold no
759
+ state and use no hooks, so they ship from the RSC-safe root.
760
+
588
761
  ### The full-invoice request — `FullInvoiceFields` (REB-73)
589
762
 
590
763
  A full invoice needs the buyer's identifier — a Spanish NIF/NIE/CIF, or for a