@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 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