@reblu/site-blocks 0.5.1 → 0.7.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 +89 -0
- package/README.md +177 -0
- 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 +690 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +351 -2
- package/dist/index.d.ts +351 -2
- package/dist/index.js +679 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,94 @@
|
|
|
1
1
|
# @reblu/site-blocks
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 21feec3: REB-836 — checkout hardening: Stripe redirect-origin check + stable checkout error codes.
|
|
8
|
+
- `@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.
|
|
9
|
+
- `@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.
|
|
10
|
+
- `@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.
|
|
11
|
+
- `@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.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
- b86a16b: Add-to-cart feedback (REB-837). `AddToCartButton` gets a built-in confirmed
|
|
16
|
+
state («Añadido ✓» for `successDurationMs`, default 1500ms, overridable
|
|
17
|
+
label), an `aria-live="polite"` announcement, a controlled `error` prop for
|
|
18
|
+
out-of-stock/max-quantity, `onAdded`/`onError` callbacks carrying the opaque
|
|
19
|
+
`item`, and a non-reentrancy guard (pointer AND keyboard) during the success
|
|
20
|
+
window. `CartIcon` gets an opt-in count "bump" (`bumpOnAdd`) that is
|
|
21
|
+
hydration-safe — pass `useCart().hydrated` through, or it bumps on every
|
|
22
|
+
page load with a non-empty persisted cart — and respects
|
|
23
|
+
`prefers-reduced-motion`. New `AddedToCartToast` organism (product name,
|
|
24
|
+
thumbnail, "Ver carrito" link, status copy) over `@reblu/site-ui`'s `Toast`
|
|
25
|
+
pattern — content only, no `aria-live` of its own (the host toaster owns
|
|
26
|
+
announcement).
|
|
27
|
+
|
|
28
|
+
**Breaking**: `AddToCartButton` and `CartIcon` now carry genuine internal
|
|
29
|
+
state and move from the RSC-safe root `.` entry to `./client` —
|
|
30
|
+
`import { AddToCartButton, CartIcon } from '@reblu/site-blocks/client'`.
|
|
31
|
+
|
|
32
|
+
README "Cart & checkout" documents the recommended pattern: a toast plus the
|
|
33
|
+
`CartIcon` badge, never an auto-opening cart drawer.
|
|
34
|
+
|
|
35
|
+
- b86a16b: `CartSummary`/`CartLineList` accept a `notice` prop (REB-837): `true` renders
|
|
36
|
+
the default «Pago cancelado: tu carrito sigue aquí» banner (overridable via
|
|
37
|
+
`labels.cancelledNotice`), any other node renders custom content, omitted/
|
|
38
|
+
`false` renders nothing — pairs with `@reblu/site-next/shop`'s
|
|
39
|
+
`useCheckoutReturn()`.
|
|
40
|
+
- b86a16b: `OrderConfirmation` renders `@reblu/site-next`'s `useShopOrderStatus` states
|
|
41
|
+
directly (REB-837): `loading`/`paid` are drop-in aliases for the pre-existing
|
|
42
|
+
`polling`/`found`, and `pending_timeout`/`failed`/`refunded`/
|
|
43
|
+
`partially_refunded`/`not_found`/`error` get their own default Spanish copy,
|
|
44
|
+
all overridable via `labels`. New optional `cartHref` (a "Volver al
|
|
45
|
+
carrito" link on `failed`) and `onRecheck` (a "Volver a comprobar" button on
|
|
46
|
+
`pending_timeout`/`error`) props. Backward compatible: the original
|
|
47
|
+
`'polling' | 'found' | 'timeout'` API and copy are unchanged.
|
|
48
|
+
- b86a16b: Gentle AI review follow-up (REB-837):
|
|
49
|
+
- `AddToCartButton`'s `onAddToCart` may now return the add's outcome
|
|
50
|
+
(`AddToCartOutcome | void | Promise<...>`) — a `'refused'` result always
|
|
51
|
+
shows the error state and calls `onError`, instead of deciding purely from
|
|
52
|
+
the `error` prop captured at render time (which showed "Añadido ✓" for a
|
|
53
|
+
refused add whenever the caller never flipped `error` on). Non-reentrant
|
|
54
|
+
while an async `onAddToCart` is pending, not just during the success window.
|
|
55
|
+
- `CartIcon`'s bump could get stuck `true` forever (a remove shortly after an
|
|
56
|
+
add cancelled the pending timeout without resetting the flag) — fixed.
|
|
57
|
+
Omitting `hydrated` entirely now means "no hydration guard, bump on every
|
|
58
|
+
increase" instead of silently disabling every bump.
|
|
59
|
+
- `OrderConfirmation` no longer renders the full "paid" order summary
|
|
60
|
+
(items, total, confirmation-email line) for `failed`/`pending_timeout`/
|
|
61
|
+
`refunded`/`partially_refunded`/`not_found`/`error` — only `paid`/`found`
|
|
62
|
+
and the legacy `timeout` state keep it. `refunded`/`partially_refunded`/
|
|
63
|
+
`pending_timeout` get a neutral order-number-only reference instead.
|
|
64
|
+
- `CartSummary` without a `notice` keeps its root as a bare `<dl>` (no extra
|
|
65
|
+
wrapper `<div>`) — the wrapper only appears when a notice actually renders.
|
|
66
|
+
|
|
67
|
+
### Patch Changes
|
|
68
|
+
|
|
69
|
+
- Updated dependencies [21feec3]
|
|
70
|
+
- Updated dependencies [b86a16b]
|
|
71
|
+
- @reblu/site-contracts@1.6.0
|
|
72
|
+
- @reblu/site-ui@0.6.0
|
|
73
|
+
|
|
74
|
+
## 0.6.0
|
|
75
|
+
|
|
76
|
+
### Minor Changes
|
|
77
|
+
|
|
78
|
+
- 74a94ff: Add cart + checkout presentational organisms (REB-829): `AddToCartButton`,
|
|
79
|
+
`CartIcon`, `CartLineList`, `CartSummary`, `CheckoutLoginPrompt`,
|
|
80
|
+
`CheckoutForm`, `OrderConfirmation`. Same container/presentational split as
|
|
81
|
+
`ShopFilters` — data + optional callbacks in, no fetching, RSC-safe by
|
|
82
|
+
construction (callbacks only do anything when supplied from a Client
|
|
83
|
+
Component). Spanish-neutral default labels, all overridable via props. Wire
|
|
84
|
+
them to `@reblu/site-next/cart`'s `useCart()` and the
|
|
85
|
+
`./shop-route-handlers`/`./shop-server` Route Handlers and server helper.
|
|
86
|
+
|
|
87
|
+
### Patch Changes
|
|
88
|
+
|
|
89
|
+
- Updated dependencies [74a94ff]
|
|
90
|
+
- @reblu/site-contracts@1.5.0
|
|
91
|
+
|
|
3
92
|
## 0.5.1
|
|
4
93
|
|
|
5
94
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -17,9 +17,11 @@ a different-looking site for every tenant, with zero forks.
|
|
|
17
17
|
| `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
|
|
18
18
|
| `Treatment`, `Specialty`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. |
|
|
19
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. |
|
|
20
21
|
| `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
|
|
21
22
|
| `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
|
|
22
23
|
| `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
|
|
24
|
+
| `AddToCartButton`, `CartIcon` | `./client` | Add-to-cart feedback (REB-837) — **moved here from `.`**, see below. |
|
|
23
25
|
|
|
24
26
|
## Boundary
|
|
25
27
|
|
|
@@ -181,6 +183,181 @@ has a selection to clear (an active filter can legitimately collapse every
|
|
|
181
183
|
facet option to zero; the component still renders "Clear filters" then,
|
|
182
184
|
never stranding the visitor with no way back).
|
|
183
185
|
|
|
186
|
+
## Cart & checkout
|
|
187
|
+
|
|
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`.
|
|
295
|
+
|
|
296
|
+
`CartLineList`'s `liveMessage` prop renders inside a visually-hidden
|
|
297
|
+
`aria-live="polite"` region — the consuming SITE sets it (e.g. "Cantidad
|
|
298
|
+
actualizada", "Producto eliminado") after a successful mutation; the
|
|
299
|
+
component only renders the live region, it never decides when to announce.
|
|
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
|
+
|
|
313
|
+
`CheckoutForm` intentionally does NOT collect shipping/billing addresses —
|
|
314
|
+
those field sets vary by tenant shop config far more than the guest-email +
|
|
315
|
+
discount-code flow. Render them via `CheckoutForm`'s `children` slot
|
|
316
|
+
(rendered between the contact and discount sections) if your shop has
|
|
317
|
+
shipping enabled.
|
|
318
|
+
|
|
319
|
+
`validate-cart`'s rejection path (out-of-stock, price-changed, empty cart)
|
|
320
|
+
surfaces through `CheckoutForm`'s own error-display conventions: pass the
|
|
321
|
+
first `ShopCartValidationError.message` from `client.shop.validateCart(...)`
|
|
322
|
+
as a toast/banner in your page (the component itself has no cart-wide error
|
|
323
|
+
slot — it only owns contact/discount field-level errors via `contactErrors`/
|
|
324
|
+
`discountError`).
|
|
325
|
+
|
|
326
|
+
### `OrderConfirmation` states (REB-837)
|
|
327
|
+
|
|
328
|
+
`status` accepts `@reblu/site-next/shop`'s `useShopOrderStatus` hook result
|
|
329
|
+
directly — `loading`/`paid` are drop-in aliases for the original
|
|
330
|
+
`polling`/`found` states, so existing callers on the pre-REB-837 3-state API
|
|
331
|
+
(`'polling' | 'found' | 'timeout'`) are completely unaffected.
|
|
332
|
+
|
|
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) | — |
|
|
344
|
+
|
|
345
|
+
**REB-837 review fix**: the full order summary (items, total, "te hemos
|
|
346
|
+
enviado un email de confirmación a…") renders ONLY for `paid`/`found` and
|
|
347
|
+
the legacy `timeout` state. `useShopOrderStatus` sets `order` from whatever
|
|
348
|
+
the last response returned regardless of `paymentStatus` — without this
|
|
349
|
+
gate, a `failed`/`refunded`/`pending_timeout` order would render the exact
|
|
350
|
+
same "confirmed purchase" summary a successful one gets. `refunded`/
|
|
351
|
+
`partially_refunded`/`pending_timeout` instead get a neutral, number-only
|
|
352
|
+
reference (`labels.orderReferenceLabel`); `failed`/`not_found`/`error`
|
|
353
|
+
render no order details at all, even if an `order` object happens to be
|
|
354
|
+
present.
|
|
355
|
+
|
|
356
|
+
Every title/description is overridable via `labels` (see
|
|
357
|
+
`OrderConfirmationLabels`). See `@reblu/site-next`'s README
|
|
358
|
+
"`useShopOrderStatus` — the order-confirmation flow" for the full wiring
|
|
359
|
+
recipe.
|
|
360
|
+
|
|
184
361
|
## Security boundaries
|
|
185
362
|
|
|
186
363
|
Two sinks, two different helpers, in two different packages — do not reach for
|
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
|