@reblu/site-blocks 0.6.0 → 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 CHANGED
@@ -1,5 +1,76 @@
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
+
3
74
  ## 0.6.0
4
75
 
5
76
  ### Minor Changes
package/README.md CHANGED
@@ -17,10 +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
- | `AddToCartButton`, `CartIcon`, `CartLineList`, `CartSummary`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
20
+ | `CartLineList`, `CartSummary`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
21
21
  | `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
22
22
  | `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
23
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
 
@@ -184,33 +185,131 @@ 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
@@ -224,6 +323,41 @@ as a toast/banner in your page (the component itself has no cart-wide error
224
323
  slot — it only owns contact/discount field-level errors via `contactErrors`/
225
324
  `discountError`).
226
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
+
227
361
  ## Security boundaries
228
362
 
229
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