@reblu/site-blocks 0.7.0 → 0.8.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,16 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c40434a: Add a distributable controlled phone input and E.164 normalization. Preserve and validate the additive checkout `details.field` in typed API errors so CheckoutForm can mark the rejected phone while retaining generic error fallback.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [c40434a]
12
+ - @reblu/site-ui@0.7.0
13
+
3
14
  ## 0.7.0
4
15
 
5
16
  ### Minor Changes
package/README.md CHANGED
@@ -10,18 +10,18 @@ a different-looking site for every tenant, with zero forks.
10
10
 
11
11
  ## What's inside
12
12
 
13
- | Export | Entry | Purpose |
14
- | ------ | ----- | ------- |
15
- | `readingTime`, `formatPrice`, `buildShareUrls`, `markdownToSafeHtml` | `.` | Pure, framework-agnostic helpers (RSC-safe). |
16
- | `sanitizeHtml` | `.` | Sanitises an HTML string before `dangerouslySetInnerHTML` — the **HTML boundary**. See "Security boundaries" below. |
17
- | `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
18
- | `Treatment`, `Specialty`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. |
19
- | `ShopFilters` | `.` | Plug-and-play shop filter chrome over `GET /api/site/shop/facets`. RSC-safe. See "Shop filters" below. |
20
- | `CartLineList`, `CartSummary`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
21
- | `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
22
- | `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
23
- | `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
24
- | `AddToCartButton`, `CartIcon` | `./client` | Add-to-cart feedback (REB-837) — **moved here from `.`**, see below. |
13
+ | Export | Entry | Purpose |
14
+ | ------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
15
+ | `readingTime`, `formatPrice`, `buildShareUrls`, `markdownToSafeHtml` | `.` | Pure, framework-agnostic helpers (RSC-safe). |
16
+ | `sanitizeHtml` | `.` | Sanitises an HTML string before `dangerouslySetInnerHTML` — the **HTML boundary**. See "Security boundaries" below. |
17
+ | `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
18
+ | `Treatment`, `Specialty`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. |
19
+ | `ShopFilters` | `.` | Plug-and-play shop filter chrome over `GET /api/site/shop/facets`. RSC-safe. See "Shop filters" below. |
20
+ | `CartLineList`, `CartSummary`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
21
+ | `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
22
+ | `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
23
+ | `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
24
+ | `AddToCartButton`, `CartIcon` | `./client` | Add-to-cart feedback (REB-837) — **moved here from `.`**, see below. |
25
25
 
26
26
  ## Boundary
27
27
 
@@ -44,7 +44,7 @@ Calle Mayor 1
44
44
  ```
45
45
 
46
46
  renders as one paragraph reading `Calle Mayor 1 28001 Madrid`. This is standard
47
- CommonMark — a lone newline is a *soft* break, i.e. whitespace — and it is what
47
+ CommonMark — a lone newline is a _soft_ break, i.e. whitespace — and it is what
48
48
  this renderer has done since it replaced the interim, non-standard one that
49
49
  emitted a `<br>` for every newline. **Text written under the old behaviour reads
50
50
  differently now**, so addresses, signature blocks and enumerated clauses typed on
@@ -52,11 +52,11 @@ consecutive lines are worth re-reading.
52
52
 
53
53
  To force a visible break, use any of:
54
54
 
55
- | Want | Write |
56
- | ---- | ----- |
57
- | A new paragraph | A blank line between the two lines |
58
- | A hard break inside one paragraph | Two spaces at the end of the first line |
59
- | A hard break, visibly | A trailing `\` at the end of the first line |
55
+ | Want | Write |
56
+ | --------------------------------- | ------------------------------------------- |
57
+ | A new paragraph | A blank line between the two lines |
58
+ | A hard break inside one paragraph | Two spaces at the end of the first line |
59
+ | A hard break, visibly | A trailing `\` at the end of the first line |
60
60
 
61
61
  ### `hidden` hides a block — it does not withhold it
62
62
 
@@ -191,16 +191,16 @@ dependency of their own. Wire them to `@reblu/site-next/cart`'s `useCart()`
191
191
  and the `./shop-route-handlers` Route Handlers (see that package's README
192
192
  for the wiring steps and a full example app).
193
193
 
194
- | Component | Entry | Props it needs | RSC-safe without callbacks? |
195
- | --------- | ----- | --------------- | ---------------------------- |
196
- | `AddToCartButton` | `./client` | `onAddToCart?`, `disabled?`, `outOfStock?`, `pending?`, `error?`, `item?`, `successDurationMs?`, `onAdded?`, `onError?` | No — carries a built-in confirmed/error timer (`useState`/`useEffect`), see below |
197
- | `CartIcon` | `./client` | `itemCount`, `href`, `hydrated?`, `bumpOnAdd?` | No — the bump animation needs state, see below |
198
- | `AddedToCartToast` | `.` | `productName`, `cartHref`, `status?`, `quantity?`, `imageUrl?` | Yes |
199
- | `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?` | Yes |
200
- | `CartSummary` | `.` | `subtotal`, `discount?`, `shipping?`, `shippingEnabled?`, `total`, `notice?` | Yes |
201
- | `CheckoutLoginPrompt` | `.` | `open`, `email`, `onEmailChange`, `onClose`, `onSubmitEmail`, `onContinueAsGuest` | Yes (renders `null` when `open` is false) |
202
- | `CheckoutForm` | `.` | `contact`, `onContactChange`, `discountCode`, `onDiscountCodeChange`, `onApplyDiscount`, `onRemoveDiscount`, `onSubmit` | Yes |
203
- | `OrderConfirmation` | `.` | `status`, `order`, `cartHref?`, `onRecheck?` | Yes |
194
+ | Component | Entry | Props it needs | RSC-safe without callbacks? |
195
+ | --------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
196
+ | `AddToCartButton` | `./client` | `onAddToCart?`, `disabled?`, `outOfStock?`, `pending?`, `error?`, `item?`, `successDurationMs?`, `onAdded?`, `onError?` | No — carries a built-in confirmed/error timer (`useState`/`useEffect`), see below |
197
+ | `CartIcon` | `./client` | `itemCount`, `href`, `hydrated?`, `bumpOnAdd?` | No — the bump animation needs state, see below |
198
+ | `AddedToCartToast` | `.` | `productName`, `cartHref`, `status?`, `quantity?`, `imageUrl?` | Yes |
199
+ | `CartLineList` | `.` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?`, `notice?` | Yes |
200
+ | `CartSummary` | `.` | `subtotal`, `discount?`, `shipping?`, `shippingEnabled?`, `total`, `notice?` | Yes |
201
+ | `CheckoutLoginPrompt` | `.` | `open`, `email`, `onEmailChange`, `onClose`, `onSubmitEmail`, `onContinueAsGuest` | Yes (renders `null` when `open` is false) |
202
+ | `CheckoutForm` | `.` | `contact`, `onContactChange`, `discountCode`, `onDiscountCodeChange`, `onApplyDiscount`, `onRemoveDiscount`, `onSubmit` | Yes |
203
+ | `OrderConfirmation` | `.` | `status`, `order`, `cartHref?`, `onRecheck?` | Yes |
204
204
 
205
205
  **REB-837 breaking move**: `AddToCartButton` and `CartIcon` now carry real
206
206
  internal state (the confirmed/error timer, the bump-on-add guard) and ship
@@ -316,6 +316,26 @@ discount-code flow. Render them via `CheckoutForm`'s `children` slot
316
316
  (rendered between the contact and discount sections) if your shop has
317
317
  shipping enabled.
318
318
 
319
+ For phone normalization, pass `phoneRegion` from the tenant's configured country:
320
+ the controlled `contact.phone` callback receives E.164 when valid and raw text
321
+ when incomplete. On a checkout API rejection pass the parsed response body to
322
+ `checkoutError` (not the transport error's prefixed `message`). Only an
323
+ `INVALID_INPUT` body with `details.field === 'customerPhone'` and a nonblank
324
+ `error` marks the phone field. Unknown/missing/malformed details show the
325
+ existing generic code message instead; other code paths are unchanged. Clear
326
+ the response on edit/retry in the consuming SITE to remove a stale server error.
327
+
328
+ ```tsx
329
+ <CheckoutForm {...checkoutProps} phoneRegion='ES' checkoutError={checkoutErrorBody} />
330
+ ```
331
+
332
+ Pass `error.body` after catching `error instanceof ApiError` from
333
+ `@reblu/site-client`. Its `body` parser now types and validates the optional
334
+ string `details.field`; never parse the transport-prefixed `error.message`.
335
+ For malformed bodies (which parse as `undefined`), pass a generic body such as
336
+ `error.body ?? { error: 'Checkout failed' }` to show the fallback banner.
337
+ An external SITE (including Dermamedicin) must adopt this wiring separately.
338
+
319
339
  `validate-cart`'s rejection path (out-of-stock, price-changed, empty cart)
320
340
  surfaces through `CheckoutForm`'s own error-display conventions: pass the
321
341
  first `ShopCartValidationError.message` from `client.shop.validateCart(...)`
@@ -330,17 +350,17 @@ directly — `loading`/`paid` are drop-in aliases for the original
330
350
  `polling`/`found` states, so existing callers on the pre-REB-837 3-state API
331
351
  (`'polling' | 'found' | 'timeout'`) are completely unaffected.
332
352
 
333
- | `status` | Default copy | Order details shown | Extra UI |
334
- | -------- | ------------- | -------------------- | -------- |
335
- | `loading` / `polling` | «Confirmando tu pedido…» | — | — |
336
- | `paid` / `found` | «¡Pedido confirmado!» | Full summary (items, total, confirmation-email line) | — |
337
- | `failed` | «El pago no se ha completado» | None | "Volver al carrito" link when `cartHref` is given |
338
- | `refunded` | «Este pedido ha sido reembolsado» | Order number only (neutral reference) | — |
339
- | `partially_refunded` | «…reembolsado parcialmente» | Order number only | — |
340
- | `pending_timeout` | «Pedido recibido. Estamos confirmando el pago…» | Order number only | "Volver a comprobar" button when `onRecheck` is given |
341
- | `not_found` | «No encontramos ningún pedido» | None | — |
342
- | `error` | «Ha ocurrido un error» | None | "Volver a comprobar" button when `onRecheck` is given |
343
- | `timeout` (legacy) | «Pago recibido» — UNCHANGED | Full summary (unchanged pre-REB-837 behavior) | — |
353
+ | `status` | Default copy | Order details shown | Extra UI |
354
+ | --------------------- | ----------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------- |
355
+ | `loading` / `polling` | «Confirmando tu pedido…» | — | — |
356
+ | `paid` / `found` | «¡Pedido confirmado!» | Full summary (items, total, confirmation-email line) | — |
357
+ | `failed` | «El pago no se ha completado» | None | "Volver al carrito" link when `cartHref` is given |
358
+ | `refunded` | «Este pedido ha sido reembolsado» | Order number only (neutral reference) | — |
359
+ | `partially_refunded` | «…reembolsado parcialmente» | Order number only | — |
360
+ | `pending_timeout` | «Pedido recibido. Estamos confirmando el pago…» | Order number only | "Volver a comprobar" button when `onRecheck` is given |
361
+ | `not_found` | «No encontramos ningún pedido» | None | — |
362
+ | `error` | «Ha ocurrido un error» | None | "Volver a comprobar" button when `onRecheck` is given |
363
+ | `timeout` (legacy) | «Pago recibido» — UNCHANGED | Full summary (unchanged pre-REB-837 behavior) | — |
344
364
 
345
365
  **REB-837 review fix**: the full order summary (items, total, "te hemos
346
366
  enviado un email de confirmación a…") renders ONLY for `paid`/`found` and
@@ -363,10 +383,10 @@ recipe.
363
383
  Two sinks, two different helpers, in two different packages — do not reach for
364
384
  one where the other belongs:
365
385
 
366
- | Sink | Helper | Package |
367
- | ---- | ------ | ------- |
368
- | HTML rendered via `dangerouslySetInnerHTML` | `sanitizeHtml` (strip/allowlist sanitiser) | `@reblu/site-blocks` (here) |
369
- | `<script type="application/ld+json">` body | `jsonLdScriptContent` / `<JsonLd>` (escaper) | [`@reblu/site-next/json-ld`](../site-next#structured-data-json-ld) |
386
+ | Sink | Helper | Package |
387
+ | ------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------ |
388
+ | HTML rendered via `dangerouslySetInnerHTML` | `sanitizeHtml` (strip/allowlist sanitiser) | `@reblu/site-blocks` (here) |
389
+ | `<script type="application/ld+json">` body | `jsonLdScriptContent` / `<JsonLd>` (escaper) | [`@reblu/site-next/json-ld`](../site-next#structured-data-json-ld) |
370
390
 
371
391
  A `<script>` body is HTML's **raw-text** context: the parser ends it on the
372
392
  first `</script` byte sequence regardless of whether that sequence sits inside
package/dist/index.cjs CHANGED
@@ -13990,6 +13990,7 @@ function CheckoutLoginPrompt({
13990
13990
 
13991
13991
  // src/components/checkout-form.tsx
13992
13992
  var import_utils12 = require("@reblu/site-ui/utils");
13993
+ var import_phone_input = require("@reblu/site-ui/phone-input");
13993
13994
  var import_jsx_runtime12 = require("react/jsx-runtime");
13994
13995
  var DEFAULT_LABELS6 = {
13995
13996
  contactTitle: "Datos de contacto",
@@ -14011,6 +14012,8 @@ function CheckoutForm({
14011
14012
  contact,
14012
14013
  onContactChange,
14013
14014
  contactErrors,
14015
+ phoneRegion,
14016
+ checkoutError,
14014
14017
  discountCode,
14015
14018
  onDiscountCodeChange,
14016
14019
  onApplyDiscount,
@@ -14029,6 +14032,10 @@ function CheckoutForm({
14029
14032
  className
14030
14033
  }) {
14031
14034
  const t = { ...DEFAULT_LABELS6, ...labels };
14035
+ const details = checkoutError?.details;
14036
+ const rejectedPhone = checkoutError?.code === "INVALID_INPUT" && typeof details === "object" && details !== null && !Array.isArray(details) && "field" in details && details.field === "customerPhone" && typeof checkoutError.error === "string" && checkoutError.error.trim().length > 0;
14037
+ const phoneError = contactErrors?.phone ?? (rejectedPhone ? checkoutError.error : void 0);
14038
+ const generalError = checkoutError && !rejectedPhone ? getShopErrorMessage(typeof checkoutError.code === "string" ? checkoutError.code : void 0) : null;
14032
14039
  function patchContact(patch3) {
14033
14040
  onContactChange({ ...contact, ...patch3 });
14034
14041
  }
@@ -14089,27 +14096,21 @@ function CheckoutForm({
14089
14096
  ),
14090
14097
  contactErrors?.email && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("p", { className: "text-xs text-destructive", children: contactErrors.email })
14091
14098
  ] }),
14092
- /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "space-y-1", children: [
14093
- /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("label", { htmlFor: "checkout-form-phone", className: "text-sm font-medium", children: t.phoneLabel }),
14094
- /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
14095
- "input",
14096
- {
14097
- id: "checkout-form-phone",
14098
- type: "tel",
14099
- autoComplete: "tel",
14100
- value: contact.phone ?? "",
14101
- onChange: (e) => patchContact({ phone: e.target.value }),
14102
- className: (0, import_utils12.cn)(
14103
- "w-full rounded-lg border bg-background px-3 py-2 text-sm transition focus:outline-none focus:ring-2 focus:ring-primary/20",
14104
- contactErrors?.phone ? "border-destructive" : "border-border focus:border-primary"
14105
- )
14106
- }
14107
- ),
14108
- contactErrors?.phone && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("p", { className: "text-xs text-destructive", children: contactErrors.phone })
14109
- ] })
14099
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "space-y-1", children: /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
14100
+ import_phone_input.PhoneInput,
14101
+ {
14102
+ id: "checkout-form-phone",
14103
+ label: t.phoneLabel,
14104
+ region: phoneRegion,
14105
+ value: contact.phone ?? "",
14106
+ onChange: (phone) => patchContact({ phone }),
14107
+ error: phoneError
14108
+ }
14109
+ ) })
14110
14110
  ] })
14111
14111
  ] }),
14112
14112
  children2,
14113
+ generalError ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("p", { role: "alert", className: "text-sm text-destructive", children: generalError }) : null,
14113
14114
  /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("section", { className: "space-y-3", children: [
14114
14115
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("h2", { className: "border-b border-border pb-2 text-lg font-semibold", children: t.discountTitle }),
14115
14116
  appliedDiscount ? /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-4 py-3 dark:border-green-800 dark:bg-green-900/20", children: [