@reblu/site-blocks 0.28.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,11 @@
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
+
3
9
  ## 0.28.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -585,6 +585,148 @@ import { OrderInvoiceLink } from '@reblu/site-blocks'
585
585
  // «Descargar la factura FAC-2026-0010» + «Te pediremos que inicies sesión para abrirla.»
586
586
  ```
587
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
+
588
730
  ### The full-invoice request — `FullInvoiceFields` (REB-73)
589
731
 
590
732
  A full invoice needs the buyer's identifier — a Spanish NIF/NIE/CIF, or for a