@reblu/site-blocks 0.6.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,87 @@
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
+
14
+ ## 0.7.0
15
+
16
+ ### Minor Changes
17
+
18
+ - 21feec3: REB-836 — checkout hardening: Stripe redirect-origin check + stable checkout error codes.
19
+ - `@reblu/site-contracts`: re-exports the new `ShopErrorCode` type (type-only, `z.infer` from base-api's `shopErrorCodeSchema`, no Zod reaches the published `.d.ts`) — nine values: `STOCK_CHANGED`, `PRICE_CHANGED`, `PRODUCT_UNAVAILABLE`, `DISCOUNT_INVALID`, `SHIPPING_UNAVAILABLE`, `INVALID_INPUT`, `STORE_DISABLED`, `PAYMENT_PROVIDER_ERROR`, and `INTERNAL_ERROR` (added in a native-review follow-up to distinguish an unexpected, non-Stripe failure — reported as HTTP 500 — from a Stripe failure, reported as 400 or, for a transient/5xx Stripe error, 503). This is the shop write endpoints' (`checkout`/`validateCart`/`applyDiscount`) new optional error-body `code` field.
20
+ - `@reblu/site-client`: (1) `shop.checkout()` now validates the returned Stripe Checkout URL is `https:` on an allowed host (default `checkout.stripe.com`, injectable via the new `RebluClientConfig.checkoutRedirectHosts`) before ever returning it, throwing the new `UnsafeCheckoutRedirectError` otherwise — exported alongside `isSafeCheckoutRedirectUrl` and `DEFAULT_CHECKOUT_REDIRECT_HOSTS`. (2) `ApiError.body` (`ApiErrorBody`/`ApiErrorDetails`) gains the optional `code` and `details.productIds` fields.
21
+ - `@reblu/site-next`: (1) `createShopCheckoutRoute` re-checks the Checkout URL server-side before responding to the browser (502 `unsafe_redirect` when it fails the check) — new `ShopCheckoutRouteOptions.allowedRedirectHosts`/`unsafeRedirectMessage`. (2) Native-review follow-up: that same route now strips `content-encoding`/`content-length`/`transfer-encoding` from its re-serialized response instead of forwarding them from the upstream proxy response, fixing a "browser tries to gunzip already-decoded JSON" failure mode.
22
+ - `@reblu/site-blocks`: adds `DEFAULT_SHOP_ERROR_MESSAGES` (a default Spanish message per `ShopErrorCode`, including `INTERNAL_ERROR`'s "Ha ocurrido un error inesperado. Inténtalo de nuevo en unos minutos.") and `getShopErrorMessage(code, overrides?, fallback?)`, which a SITE can override per code without restating the others.
23
+
24
+ All new fields/exports are additive and optional (REB-788) — a SITE that ignores `code`/`checkoutRedirectHosts` keeps working unchanged. The base-api side of this change (the `ShopErrorCode` schema itself, `checkout.service.ts`'s Stripe-vs-internal error classification, and `apiError()`'s options-object signature) is unpublished — it ships with the next base-api deploy, not through these changesets.
25
+
26
+ - b86a16b: Add-to-cart feedback (REB-837). `AddToCartButton` gets a built-in confirmed
27
+ state («Añadido ✓» for `successDurationMs`, default 1500ms, overridable
28
+ label), an `aria-live="polite"` announcement, a controlled `error` prop for
29
+ out-of-stock/max-quantity, `onAdded`/`onError` callbacks carrying the opaque
30
+ `item`, and a non-reentrancy guard (pointer AND keyboard) during the success
31
+ window. `CartIcon` gets an opt-in count "bump" (`bumpOnAdd`) that is
32
+ hydration-safe — pass `useCart().hydrated` through, or it bumps on every
33
+ page load with a non-empty persisted cart — and respects
34
+ `prefers-reduced-motion`. New `AddedToCartToast` organism (product name,
35
+ thumbnail, "Ver carrito" link, status copy) over `@reblu/site-ui`'s `Toast`
36
+ pattern — content only, no `aria-live` of its own (the host toaster owns
37
+ announcement).
38
+
39
+ **Breaking**: `AddToCartButton` and `CartIcon` now carry genuine internal
40
+ state and move from the RSC-safe root `.` entry to `./client` —
41
+ `import { AddToCartButton, CartIcon } from '@reblu/site-blocks/client'`.
42
+
43
+ README "Cart & checkout" documents the recommended pattern: a toast plus the
44
+ `CartIcon` badge, never an auto-opening cart drawer.
45
+
46
+ - b86a16b: `CartSummary`/`CartLineList` accept a `notice` prop (REB-837): `true` renders
47
+ the default «Pago cancelado: tu carrito sigue aquí» banner (overridable via
48
+ `labels.cancelledNotice`), any other node renders custom content, omitted/
49
+ `false` renders nothing — pairs with `@reblu/site-next/shop`'s
50
+ `useCheckoutReturn()`.
51
+ - b86a16b: `OrderConfirmation` renders `@reblu/site-next`'s `useShopOrderStatus` states
52
+ directly (REB-837): `loading`/`paid` are drop-in aliases for the pre-existing
53
+ `polling`/`found`, and `pending_timeout`/`failed`/`refunded`/
54
+ `partially_refunded`/`not_found`/`error` get their own default Spanish copy,
55
+ all overridable via `labels`. New optional `cartHref` (a "Volver al
56
+ carrito" link on `failed`) and `onRecheck` (a "Volver a comprobar" button on
57
+ `pending_timeout`/`error`) props. Backward compatible: the original
58
+ `'polling' | 'found' | 'timeout'` API and copy are unchanged.
59
+ - b86a16b: Gentle AI review follow-up (REB-837):
60
+ - `AddToCartButton`'s `onAddToCart` may now return the add's outcome
61
+ (`AddToCartOutcome | void | Promise<...>`) — a `'refused'` result always
62
+ shows the error state and calls `onError`, instead of deciding purely from
63
+ the `error` prop captured at render time (which showed "Añadido ✓" for a
64
+ refused add whenever the caller never flipped `error` on). Non-reentrant
65
+ while an async `onAddToCart` is pending, not just during the success window.
66
+ - `CartIcon`'s bump could get stuck `true` forever (a remove shortly after an
67
+ add cancelled the pending timeout without resetting the flag) — fixed.
68
+ Omitting `hydrated` entirely now means "no hydration guard, bump on every
69
+ increase" instead of silently disabling every bump.
70
+ - `OrderConfirmation` no longer renders the full "paid" order summary
71
+ (items, total, confirmation-email line) for `failed`/`pending_timeout`/
72
+ `refunded`/`partially_refunded`/`not_found`/`error` — only `paid`/`found`
73
+ and the legacy `timeout` state keep it. `refunded`/`partially_refunded`/
74
+ `pending_timeout` get a neutral order-number-only reference instead.
75
+ - `CartSummary` without a `notice` keeps its root as a bare `<dl>` (no extra
76
+ wrapper `<div>`) — the wrapper only appears when a notice actually renders.
77
+
78
+ ### Patch Changes
79
+
80
+ - Updated dependencies [21feec3]
81
+ - Updated dependencies [b86a16b]
82
+ - @reblu/site-contracts@1.6.0
83
+ - @reblu/site-ui@0.6.0
84
+
3
85
  ## 0.6.0
4
86
 
5
87
  ### Minor Changes
package/README.md CHANGED
@@ -10,17 +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
- | `AddToCartButton`, `CartIcon`, `CartLineList`, `CartSummary`, `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). |
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. |
24
25
 
25
26
  ## Boundary
26
27
 
@@ -43,7 +44,7 @@ Calle Mayor 1
43
44
  ```
44
45
 
45
46
  renders as one paragraph reading `Calle Mayor 1 28001 Madrid`. This is standard
46
- 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
47
48
  this renderer has done since it replaced the interim, non-standard one that
48
49
  emitted a `<br>` for every newline. **Text written under the old behaviour reads
49
50
  differently now**, so addresses, signature blocks and enumerated clauses typed on
@@ -51,11 +52,11 @@ consecutive lines are worth re-reading.
51
52
 
52
53
  To force a visible break, use any of:
53
54
 
54
- | Want | Write |
55
- | ---- | ----- |
56
- | A new paragraph | A blank line between the two lines |
57
- | A hard break inside one paragraph | Two spaces at the end of the first line |
58
- | 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 |
59
60
 
60
61
  ### `hidden` hides a block — it does not withhold it
61
62
 
@@ -184,39 +185,157 @@ never stranding the visitor with no way back).
184
185
 
185
186
  ## Cart & checkout
186
187
 
187
- Seven organisms over the cart/checkout flow (REB-829), all presentational —
188
- data + optional callbacks in, no fetching, no cart/zustand dependency of
189
- their own. Wire them to `@reblu/site-next/cart`'s `useCart()` and the
190
- `./shop-route-handlers` Route Handlers (see that package's README for the
191
- wiring steps and a full example app).
192
-
193
- | Component | Props it needs | RSC-safe without callbacks? |
194
- | --------- | --------------- | ---------------------------- |
195
- | `AddToCartButton` | `onAddToCart?`, `disabled?`, `outOfStock?`, `pending?` | Yes |
196
- | `CartIcon` | `itemCount`, `href` | Yes |
197
- | `CartLineList` | `items`, `onQuantityChange?`, `onRemove?`, `liveMessage?` | Yes |
198
- | `CartSummary` | `subtotal`, `discount?`, `shipping?`, `shippingEnabled?`, `total` | Yes |
199
- | `CheckoutLoginPrompt` | `open`, `email`, `onEmailChange`, `onClose`, `onSubmitEmail`, `onContinueAsGuest` | Yes (renders `null` when `open` is false) |
200
- | `CheckoutForm` | `contact`, `onContactChange`, `discountCode`, `onDiscountCodeChange`, `onApplyDiscount`, `onRemoveDiscount`, `onSubmit` | Yes |
201
- | `OrderConfirmation` | `status` (`'polling' \| 'found' \| 'timeout'`), `order` | Yes |
202
-
203
- Same RSC-safety contract as `ShopFilters`'s `OptionLink`: none of these
204
- components carry `'use client'` themselves — a Server Component may render
205
- any of them read-only (omit the callback props), and a Client Component
206
- wires the callbacks to real state/mutations. All labels default to
207
- Spanish-neutral copy and are overridable via each component's `labels` prop.
188
+ Organisms over the cart/checkout flow (REB-829, extended REB-837), all
189
+ presentational — data + optional callbacks in, no fetching, no cart/zustand
190
+ dependency of their own. Wire them to `@reblu/site-next/cart`'s `useCart()`
191
+ and the `./shop-route-handlers` Route Handlers (see that package's README
192
+ for the wiring steps and a full example app).
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 |
204
+
205
+ **REB-837 breaking move**: `AddToCartButton` and `CartIcon` now carry real
206
+ internal state (the confirmed/error timer, the bump-on-add guard) and ship
207
+ from `@reblu/site-blocks/client` instead of the root `.` entry. Update the
208
+ import:
209
+
210
+ ```diff
211
+ -import { AddToCartButton, CartIcon } from '@reblu/site-blocks'
212
+ +import { AddToCartButton, CartIcon } from '@reblu/site-blocks/client'
213
+ ```
214
+
215
+ Every other organism above keeps the same RSC-safety contract as
216
+ `ShopFilters`'s `OptionLink`: none of them carry `'use client'` themselves —
217
+ a Server Component may render any of them read-only (omit the callback
218
+ props), and a Client Component wires the callbacks to real state/mutations.
219
+ All labels default to Spanish-neutral copy and are overridable via each
220
+ component's `labels` prop.
221
+
222
+ ### Add-to-cart feedback (REB-837): the recommended pattern
223
+
224
+ The recommended pattern is **a toast plus the `CartIcon` badge — never an
225
+ auto-opening cart drawer**. An auto-opening drawer steals focus and
226
+ interrupts a visitor who is still browsing; a toast + badge confirms the add
227
+ without moving them anywhere.
228
+
229
+ ```tsx
230
+ 'use client'
231
+ import { AddToCartButton, AddedToCartToast } from '@reblu/site-blocks/client'
232
+ import { useCart } from '@reblu/site-next/cart'
233
+ import { toast } from 'sonner' // or any toaster — see site-ui's Toast primitive
234
+
235
+ function AddToCart({ product }: { product: Product }) {
236
+ const { addItem } = useCart()
237
+
238
+ return (
239
+ <AddToCartButton
240
+ item={product}
241
+ onAddToCart={(item) => {
242
+ const outcome = addItem({ productId: item.id, /* … */ quantity: 1 })
243
+ // Stable id per product: a repeat add on the SAME product UPDATES
244
+ // the existing toast (shows the new resulting quantity) instead of
245
+ // stacking a second one.
246
+ toast.custom(
247
+ () => (
248
+ <AddedToCartToast
249
+ productName={product.name}
250
+ imageUrl={product.imageUrl}
251
+ quantity={outcome.quantity}
252
+ status={outcome.status === 'refused' ? 'outOfStock' : 'success'}
253
+ cartHref='/shop/cart'
254
+ />
255
+ ),
256
+ { id: `cart-add-${product.id}` },
257
+ )
258
+ }}
259
+ />
260
+ )
261
+ }
262
+ ```
263
+
264
+ `AddToCartButton`'s own confirmed/error state (`«Añadido ✓»` for
265
+ `successDurationMs` — default 1500ms, then reverts; `aria-live="polite"`
266
+ so the label change is announced) is independent of whatever toast you
267
+ show — the button never knows about the toast, and the toast never knows
268
+ about the button. Both read `addItem`'s return value
269
+ (`{ status: 'added' | 'clamped' | 'refused', quantity }`, REB-837) to decide
270
+ their own copy.
271
+
272
+ `AddedToCartToast` renders NO `aria-live` region of its own — your toaster
273
+ (sonner or otherwise) already owns one; adding a second would double-announce.
274
+
275
+ `CartIcon`'s bump only fires on a REAL increase, never on the rehydration
276
+ jump (`itemCount` going `0 -> N` the instant `CartProvider` finishes
277
+ rehydrating a non-empty persisted cart — see `@reblu/site-next`'s README "The
278
+ hydration gotcha"). Always pass `hydrated` through when you set `bumpOnAdd`:
279
+
280
+ ```tsx
281
+ const { itemCount, hydrated } = useCart()
282
+ <CartIcon itemCount={itemCount} href='/shop/cart' hydrated={hydrated} bumpOnAdd />
283
+ ```
284
+
285
+ `hydrated` is a guard you opt INTO by passing it, not a value `CartIcon`
286
+ otherwise assumes `false`: omitting it entirely disables the rehydration
287
+ guard and bumps on every `itemCount` increase — only reach for that when
288
+ `itemCount` has no client-side rehydration step at all (e.g. computed
289
+ server-side and never changing after mount). Passing `hydrated={false}`
290
+ explicitly is different: it keeps the guard active and means "not hydrated
291
+ yet", so no bump fires until a later render passes `hydrated={true}`.
292
+
293
+ The animation itself is `motion-safe:`-gated (Tailwind), so it's inert under
294
+ `prefers-reduced-motion: reduce`.
208
295
 
209
296
  `CartLineList`'s `liveMessage` prop renders inside a visually-hidden
210
297
  `aria-live="polite"` region — the consuming SITE sets it (e.g. "Cantidad
211
298
  actualizada", "Producto eliminado") after a successful mutation; the
212
299
  component only renders the live region, it never decides when to announce.
213
300
 
301
+ `CartLineList`/`CartSummary`'s `notice` prop (REB-837) renders a
302
+ `role="status"` banner above the content: `true` shows the default «Pago
303
+ cancelado: tu carrito sigue aquí» copy (overridable via
304
+ `labels.cancelledNotice`), any other `ReactNode` renders custom content,
305
+ `false`/omitted renders nothing. Pair it with `@reblu/site-next/shop`'s
306
+ `useCheckoutReturn()`:
307
+
308
+ ```tsx
309
+ const { status } = useCheckoutReturn()
310
+ <CartSummary {...summaryProps} notice={status === 'cancelled'} />
311
+ ```
312
+
214
313
  `CheckoutForm` intentionally does NOT collect shipping/billing addresses —
215
314
  those field sets vary by tenant shop config far more than the guest-email +
216
315
  discount-code flow. Render them via `CheckoutForm`'s `children` slot
217
316
  (rendered between the contact and discount sections) if your shop has
218
317
  shipping enabled.
219
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
+
220
339
  `validate-cart`'s rejection path (out-of-stock, price-changed, empty cart)
221
340
  surfaces through `CheckoutForm`'s own error-display conventions: pass the
222
341
  first `ShopCartValidationError.message` from `client.shop.validateCart(...)`
@@ -224,15 +343,50 @@ as a toast/banner in your page (the component itself has no cart-wide error
224
343
  slot — it only owns contact/discount field-level errors via `contactErrors`/
225
344
  `discountError`).
226
345
 
346
+ ### `OrderConfirmation` states (REB-837)
347
+
348
+ `status` accepts `@reblu/site-next/shop`'s `useShopOrderStatus` hook result
349
+ directly — `loading`/`paid` are drop-in aliases for the original
350
+ `polling`/`found` states, so existing callers on the pre-REB-837 3-state API
351
+ (`'polling' | 'found' | 'timeout'`) are completely unaffected.
352
+
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) | — |
364
+
365
+ **REB-837 review fix**: the full order summary (items, total, "te hemos
366
+ enviado un email de confirmación a…") renders ONLY for `paid`/`found` and
367
+ the legacy `timeout` state. `useShopOrderStatus` sets `order` from whatever
368
+ the last response returned regardless of `paymentStatus` — without this
369
+ gate, a `failed`/`refunded`/`pending_timeout` order would render the exact
370
+ same "confirmed purchase" summary a successful one gets. `refunded`/
371
+ `partially_refunded`/`pending_timeout` instead get a neutral, number-only
372
+ reference (`labels.orderReferenceLabel`); `failed`/`not_found`/`error`
373
+ render no order details at all, even if an `order` object happens to be
374
+ present.
375
+
376
+ Every title/description is overridable via `labels` (see
377
+ `OrderConfirmationLabels`). See `@reblu/site-next`'s README
378
+ "`useShopOrderStatus` — the order-confirmation flow" for the full wiring
379
+ recipe.
380
+
227
381
  ## Security boundaries
228
382
 
229
383
  Two sinks, two different helpers, in two different packages — do not reach for
230
384
  one where the other belongs:
231
385
 
232
- | Sink | Helper | Package |
233
- | ---- | ------ | ------- |
234
- | HTML rendered via `dangerouslySetInnerHTML` | `sanitizeHtml` (strip/allowlist sanitiser) | `@reblu/site-blocks` (here) |
235
- | `<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) |
236
390
 
237
391
  A `<script>` body is HTML's **raw-text** context: the parser ends it on the
238
392
  first `</script` byte sequence regardless of whether that sequence sits inside
package/dist/client.cjs CHANGED
@@ -31,6 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  // src/client.ts
32
32
  var client_exports = {};
33
33
  __export(client_exports, {
34
+ AddToCartButton: () => AddToCartButton,
35
+ CartIcon: () => CartIcon,
34
36
  Collection: () => Collection,
35
37
  ShareButtons: () => ShareButtons,
36
38
  useCollection: () => useCollection
@@ -326,8 +328,204 @@ function ShareButtons({ url, title, className, labels }) {
326
328
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { role: "status", "aria-live": "polite", className: "sr-only", children: copied ? t.copyStatus : "" })
327
329
  ] });
328
330
  }
331
+
332
+ // src/components/add-to-cart-button.tsx
333
+ var import_react2 = require("react");
334
+ var import_utils3 = require("@reblu/site-ui/utils");
335
+ var import_jsx_runtime3 = require("react/jsx-runtime");
336
+ var DEFAULT_LABELS3 = {
337
+ add: "A\xF1adir al carrito",
338
+ adding: "A\xF1adiendo\u2026",
339
+ outOfStock: "Agotado",
340
+ added: "A\xF1adido \u2713",
341
+ error: "No se pudo a\xF1adir"
342
+ };
343
+ function AddToCartButton({
344
+ onAddToCart,
345
+ disabled,
346
+ outOfStock,
347
+ pending,
348
+ error,
349
+ item,
350
+ successDurationMs = 1500,
351
+ onAdded,
352
+ onError,
353
+ labels,
354
+ className
355
+ }) {
356
+ const t = { ...DEFAULT_LABELS3, ...labels };
357
+ const [status, setStatus] = (0, import_react2.useState)("idle");
358
+ const timeoutRef = (0, import_react2.useRef)(null);
359
+ const prevErrorRef = (0, import_react2.useRef)(error);
360
+ const inSuccessWindowRef = (0, import_react2.useRef)(false);
361
+ const resolvingRef = (0, import_react2.useRef)(false);
362
+ const mountedRef = (0, import_react2.useRef)(true);
363
+ const [resolving, setResolving] = (0, import_react2.useState)(false);
364
+ const clearTimer = () => {
365
+ if (timeoutRef.current) {
366
+ clearTimeout(timeoutRef.current);
367
+ timeoutRef.current = null;
368
+ }
369
+ };
370
+ (0, import_react2.useEffect)(() => {
371
+ mountedRef.current = true;
372
+ return () => {
373
+ mountedRef.current = false;
374
+ clearTimer();
375
+ };
376
+ }, []);
377
+ const triggerSuccess = () => {
378
+ inSuccessWindowRef.current = true;
379
+ clearTimer();
380
+ setStatus("success");
381
+ onAdded?.(item);
382
+ timeoutRef.current = setTimeout(() => {
383
+ inSuccessWindowRef.current = false;
384
+ setStatus("idle");
385
+ }, successDurationMs);
386
+ };
387
+ const triggerError = (errorValue = true) => {
388
+ clearTimer();
389
+ inSuccessWindowRef.current = false;
390
+ setStatus("error");
391
+ onError?.(item, errorValue);
392
+ timeoutRef.current = setTimeout(() => setStatus("idle"), successDurationMs);
393
+ };
394
+ (0, import_react2.useEffect)(() => {
395
+ if (error && !prevErrorRef.current) {
396
+ triggerError(error);
397
+ }
398
+ prevErrorRef.current = error;
399
+ }, [error]);
400
+ const applyOutcome = (result) => {
401
+ if (!mountedRef.current || outOfStock) return;
402
+ if (result?.status === "refused") {
403
+ triggerError(true);
404
+ return;
405
+ }
406
+ if (error) return;
407
+ triggerSuccess();
408
+ };
409
+ const handleClick = () => {
410
+ if (inSuccessWindowRef.current || resolvingRef.current) return;
411
+ const result = onAddToCart?.(item);
412
+ if (result && typeof result.then === "function") {
413
+ resolvingRef.current = true;
414
+ setResolving(true);
415
+ result.then((resolved) => {
416
+ resolvingRef.current = false;
417
+ if (mountedRef.current) setResolving(false);
418
+ applyOutcome(resolved);
419
+ }).catch(() => {
420
+ resolvingRef.current = false;
421
+ if (mountedRef.current) setResolving(false);
422
+ applyOutcome({ status: "refused" });
423
+ });
424
+ return;
425
+ }
426
+ applyOutcome(result);
427
+ };
428
+ const isDisabled = Boolean(disabled || outOfStock || pending || status === "success" || resolving);
429
+ const label = outOfStock ? t.outOfStock : status === "success" ? t.added : status === "error" ? typeof error === "string" ? error : t.error : pending || resolving ? t.adding : t.add;
430
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
431
+ "button",
432
+ {
433
+ type: "button",
434
+ onClick: handleClick,
435
+ disabled: isDisabled,
436
+ "aria-disabled": isDisabled,
437
+ "aria-live": "polite",
438
+ "data-slot": "add-to-cart-button",
439
+ "data-status": status,
440
+ className: (0, import_utils3.cn)(
441
+ "inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
442
+ isDisabled ? "cursor-not-allowed bg-muted text-muted-foreground" : "bg-primary text-primary-foreground hover:bg-primary/90",
443
+ status === "error" && "bg-destructive text-destructive-foreground",
444
+ className
445
+ ),
446
+ children: label
447
+ }
448
+ );
449
+ }
450
+
451
+ // src/components/cart-icon.tsx
452
+ var import_react3 = require("react");
453
+ var import_utils4 = require("@reblu/site-ui/utils");
454
+ var import_jsx_runtime4 = require("react/jsx-runtime");
455
+ var DEFAULT_LABELS4 = {
456
+ cart: "Carrito",
457
+ itemsSuffix: (count) => `${count} art\xEDculo${count === 1 ? "" : "s"}`
458
+ };
459
+ var BUMP_DURATION_MS = 400;
460
+ function CartIcon({ itemCount, href, hydrated, bumpOnAdd, labels, className }) {
461
+ const t = { ...DEFAULT_LABELS4, ...labels };
462
+ const ariaLabel = itemCount > 0 ? `${t.cart} (${t.itemsSuffix(itemCount)})` : t.cart;
463
+ const displayCount = itemCount > 99 ? "99+" : String(itemCount);
464
+ const [bumping, setBumping] = (0, import_react3.useState)(false);
465
+ const prevCountRef = (0, import_react3.useRef)(itemCount);
466
+ const prevHydratedRef = (0, import_react3.useRef)(hydrated);
467
+ (0, import_react3.useEffect)(() => {
468
+ const hydrationGuardSatisfied = hydrated === void 0 || prevHydratedRef.current === true;
469
+ const shouldBump = Boolean(bumpOnAdd) && itemCount > prevCountRef.current && hydrationGuardSatisfied;
470
+ prevCountRef.current = itemCount;
471
+ prevHydratedRef.current = hydrated;
472
+ if (!shouldBump) {
473
+ setBumping(false);
474
+ return;
475
+ }
476
+ setBumping(true);
477
+ const id = setTimeout(() => setBumping(false), BUMP_DURATION_MS);
478
+ return () => clearTimeout(id);
479
+ }, [itemCount, hydrated, bumpOnAdd]);
480
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
481
+ "a",
482
+ {
483
+ href,
484
+ "aria-label": ariaLabel,
485
+ "data-slot": "cart-icon",
486
+ "data-bumping": bumping || void 0,
487
+ className: (0, import_utils4.cn)(
488
+ "relative inline-flex items-center justify-center rounded-lg p-2 text-foreground/80 transition-colors hover:bg-muted/60 hover:text-foreground",
489
+ className
490
+ ),
491
+ children: [
492
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
493
+ "svg",
494
+ {
495
+ viewBox: "0 0 24 24",
496
+ fill: "none",
497
+ stroke: "currentColor",
498
+ strokeWidth: 2,
499
+ strokeLinecap: "round",
500
+ strokeLinejoin: "round",
501
+ className: (0, import_utils4.cn)("h-5 w-5", bumping && "motion-safe:scale-125 motion-safe:transition-transform motion-safe:duration-300"),
502
+ "aria-hidden": "true",
503
+ children: [
504
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("circle", { cx: "9", cy: "21", r: "1" }),
505
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("circle", { cx: "20", cy: "21", r: "1" }),
506
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("path", { d: "M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" })
507
+ ]
508
+ }
509
+ ),
510
+ itemCount > 0 && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
511
+ "span",
512
+ {
513
+ "aria-hidden": "true",
514
+ className: (0, import_utils4.cn)(
515
+ "absolute -top-0.5 -right-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold leading-none tabular-nums text-primary-foreground",
516
+ bumping && "motion-safe:scale-125 motion-safe:transition-transform motion-safe:duration-300"
517
+ ),
518
+ children: displayCount
519
+ }
520
+ )
521
+ ]
522
+ }
523
+ );
524
+ }
329
525
  // Annotate the CommonJS export names for ESM import in node:
330
526
  0 && (module.exports = {
527
+ AddToCartButton,
528
+ CartIcon,
331
529
  Collection,
332
530
  ShareButtons,
333
531
  useCollection