@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 +82 -0
- package/README.md +196 -42
- package/dist/client.cjs +198 -0
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +114 -1
- package/dist/client.d.ts +114 -1
- package/dist/client.js +196 -0
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +316 -238
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +140 -55
- package/dist/index.d.ts +140 -55
- package/dist/index.js +290 -216
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
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
|
|
14
|
-
|
|
|
15
|
-
| `readingTime`, `formatPrice`, `buildShareUrls`, `markdownToSafeHtml`
|
|
16
|
-
| `sanitizeHtml`
|
|
17
|
-
| `MarkdownRenderer`
|
|
18
|
-
| `Treatment`, `Specialty`, `ProfileCard`, `ProductCard`
|
|
19
|
-
| `ShopFilters`
|
|
20
|
-
| `
|
|
21
|
-
| `useCollection`
|
|
22
|
-
| `Collection`
|
|
23
|
-
| `ShareButtons`
|
|
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
|
|
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
|
|
55
|
-
|
|
|
56
|
-
| A new paragraph
|
|
57
|
-
| A hard break inside one paragraph | Two spaces at the end of the first line
|
|
58
|
-
| A hard break, visibly
|
|
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
|
-
|
|
188
|
-
data + optional callbacks in, no fetching, no cart/zustand
|
|
189
|
-
their own. Wire them to `@reblu/site-next/cart`'s `useCart()`
|
|
190
|
-
`./shop-route-handlers` Route Handlers (see that package's README
|
|
191
|
-
wiring steps and a full example app).
|
|
192
|
-
|
|
193
|
-
| Component | Props it needs
|
|
194
|
-
|
|
|
195
|
-
| `AddToCartButton` | `onAddToCart?`, `disabled?`, `outOfStock?`, `pending?` |
|
|
196
|
-
| `CartIcon` | `itemCount`, `href` |
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
200
|
-
| `
|
|
201
|
-
| `
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
233
|
-
|
|
|
234
|
-
| HTML rendered via `dangerouslySetInnerHTML` | `sanitizeHtml` (strip/allowlist sanitiser)
|
|
235
|
-
| `<script type="application/ld+json">` body
|
|
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
|