@reblu/site-next 1.14.0 → 1.15.1

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-next
2
2
 
3
+ ## 1.15.1
4
+
5
+ ### Patch Changes
6
+
7
+ - f0b1d63: Fix `useShopOrderStatus` never resolving `paid` when wired as documented (REB-839).
8
+ `createShopOrderRoute` forwards base-api's body verbatim, and base-api wraps every
9
+ payload as `{ data: <order> }`; the hook read `paymentStatus` off the raw body, so
10
+ every order looked PENDING (`pending_timeout` after the window, cart never cleared,
11
+ `order` holding the envelope). The hook now unwraps `body.data` when present and
12
+ still accepts a bare order from a custom route.
13
+
14
+ Also: `addItem` on a line already at `maxStock` (or reconciled down by fresher
15
+ stock) now returns `{ status: 'refused' }` instead of `'clamped'`, so
16
+ `AddToCartButton` and the toast no longer report «Añadido ✓» when nothing was
17
+ added. `quantity` remains the resulting line quantity (README updated).
18
+
19
+ ## 1.15.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 21feec3: REB-836 — checkout hardening: Stripe redirect-origin check + stable checkout error codes.
24
+ - `@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.
25
+ - `@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.
26
+ - `@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.
27
+ - `@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.
28
+
29
+ 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.
30
+
31
+ - b86a16b: `addItem` (REB-837) now returns `{ status: 'added' | 'clamped' | 'refused',
32
+ quantity }` instead of `void` — `quantity` is the resulting line quantity.
33
+ Backward compatible: existing callers that ignore the return value are
34
+ unaffected. `useCart()` gains `hydrated: boolean` (also exported standalone
35
+ as `useCartHydrated()`), built with `useSyncExternalStore` over zustand
36
+ persist's `onFinishHydration`/`hasHydrated` — fixes the "bump on every page
37
+ load" bug: `CartIcon`'s `bumpOnAdd` guard needs this to tell a real add apart
38
+ from the `itemCount` 0 -> N jump that `CartProvider`'s post-mount rehydration
39
+ causes on every page load with a non-empty persisted cart. README documents
40
+ the exact persisted shape (pinned by a test) and the hydration gotcha.
41
+ - b86a16b: Standard success/cancel URLs (REB-837). `createShopCheckoutRoute` now
42
+ accepts `successPath`/`cancelPath`/`cartPath` and injects `successUrl`/
43
+ `cancelUrl` into the posted body — absolute from the REQUEST's own origin,
44
+ never a caller-supplied one — when the body doesn't already carry them; an
45
+ explicit value on the body always wins, so this is fully backward
46
+ compatible. Defaults to `/cart?pago=ok&session_id={CHECKOUT_SESSION_ID}` and
47
+ `/cart?pago=cancelado`. New `useCheckoutReturn()` (`./shop`) parses that
48
+ return query. `createProxyRoute` gained a small supporting `transformBody`
49
+ option (generic, additive) that this feature is built on.
50
+ - b86a16b: Add `useShopOrderStatus(sessionId, opts?)` from `./shop` (REB-837): polls the
51
+ SITE's own order Route Handler with an `AbortController`, for a configurable
52
+ window (default 30s) with backoff, and clears the cart ONLY once the order
53
+ resolves to PAID. Returns `loading | paid | pending_timeout | failed |
54
+ refunded | partially_refunded | not_found | error`, mapped from
55
+ `ShopOrder.paymentStatus` and the route's own 404, with a `recheck()` to
56
+ restart the poll loop. Fixes a real infinite-loop bug where a caller passing
57
+ the natural `{ buildUrl: (id) => ... }` object literal would re-fetch
58
+ forever — `buildUrl` is now read through a ref, not a polling-effect
59
+ dependency.
60
+ - b86a16b: Gentle AI review follow-up (REB-837):
61
+ - `useShopOrderStatus` gains `requestTimeoutMs` (default 8000ms) — a single
62
+ hung/slow request is now aborted and retried, and `timeoutMs` (the WHOLE
63
+ polling window) is enforced by its own independent timer, not by checking
64
+ elapsed time inside `poll()`. Previously a request that never settled left
65
+ `status` stuck on `'loading'` forever, with no way to ever reach
66
+ `pending_timeout`.
67
+ - `useCartStore`'s `globalThis`-keyed singleton is now versioned
68
+ (`CART_STORE_PERSIST_VERSION`, folded into the registry key) and a new
69
+ `__resetCartStoreForTests()` clears the slot for tests that need to prove
70
+ something about the singleton mechanism itself. New test
71
+ (`tests/cart-store-cross-entry-singleton.test.ts`) proves `./cart` and
72
+ `./shop` resolve to the exact same store instance by building and loading
73
+ both real entries.
74
+
75
+ ### Patch Changes
76
+
77
+ - b86a16b: Fix a cross-entry singleton bug in `useCartStore` (REB-837, found while
78
+ adopting `useShopOrderStatus` in demo-site): `splitting: false` bundles each
79
+ tsup entry standalone, so `./shop`'s relative import of `./cart/store`
80
+ inlined a SEPARATE store instance — `useShopOrderStatus`'s cart-clearing ran
81
+ against a disconnected store, never the one `@reblu/site-next/cart`
82
+ consumers actually read. `useCartStore` is now a `globalThis`-keyed
83
+ singleton (`Symbol.for(...)`), so every entry that imports it resolves to
84
+ the SAME instance regardless of how many times esbuild inlines the module.
85
+ No API change.
86
+ - Updated dependencies [21feec3]
87
+ - Updated dependencies [b86a16b]
88
+ - @reblu/site-contracts@1.6.0
89
+ - @reblu/site-client@5.3.0
90
+ - @reblu/site-ui@0.6.0
91
+
3
92
  ## 1.14.0
4
93
 
5
94
  ### Minor Changes
package/README.md CHANGED
@@ -322,12 +322,14 @@ its own Client Component.
322
322
  | Export | Subpath | Needs `'use client'`? |
323
323
  | ------ | ------- | ---------------------- |
324
324
  | `CartProvider`, `useCart()`, `useCartStore`, `CartItem` | `./cart` | **Yes** |
325
- | `createShopCheckoutRoute()` | `./shop-route-handlers` | No |
325
+ | `createShopCheckoutRoute(opts?)` | `./shop-route-handlers` | No |
326
326
  | `createShopValidateCartRoute()` | `./shop-route-handlers` | No |
327
327
  | `createShopApplyDiscountRoute()` | `./shop-route-handlers` | No |
328
328
  | `createShopCheckEmailRoute()` | `./shop-route-handlers` | No |
329
329
  | `createShopOrderRoute()` | `./shop-route-handlers` | No |
330
330
  | `getShopOrderSummary(client, sessionId)` | `./shop-server` | No |
331
+ | `useShopOrderStatus(sessionId, opts?)` | `./shop` | **Yes** |
332
+ | `useCheckoutReturn()` | `./shop` | **Yes** |
331
333
 
332
334
  ### Wiring a SITE
333
335
 
@@ -392,6 +394,193 @@ load-bearing: it stops the store from reading `localStorage` during SSR
392
394
  `useCartStore.persist.rehydrate()` in a `useEffect` — mount it once, or the
393
395
  cart UI renders permanently empty.
394
396
 
397
+ **The exact persisted shape** (pinned by a test — `store.test.ts`, "persisted
398
+ shape (REB-837 pin)" — any future change to it MUST bump the zustand
399
+ `persist` `version` option with a migration, never edit the shape in place):
400
+
401
+ ```json
402
+ {
403
+ "state": {
404
+ "items": [
405
+ {
406
+ "productId": "p1",
407
+ "variantId": null,
408
+ "name": "Serum",
409
+ "variantName": "50ml",
410
+ "unitPrice": 1999,
411
+ "quantity": 2,
412
+ "imageUrl": "https://cdn.example.com/serum.png",
413
+ "maxStock": 10,
414
+ "taxRateBps": 2100,
415
+ "taxRateName": "IVA 21%"
416
+ }
417
+ ]
418
+ },
419
+ "version": 0
420
+ }
421
+ ```
422
+
423
+ No expiry — a cart written today is read back unchanged next month. Nothing
424
+ clears it except an explicit `clearCart()` call, and the ONLY built-in caller
425
+ of that is `useShopOrderStatus` (below), and only when the order resolves to
426
+ `paid`.
427
+
428
+ `useCartStore` is a `globalThis`-keyed singleton versioned by
429
+ `CART_STORE_PERSIST_VERSION` (REB-837 review fix): every tsup entry that
430
+ reaches for the store — `./cart` directly, `./shop` via `useShopOrderStatus`
431
+ — resolves to the exact same instance, never a disconnected copy a build
432
+ accidentally inlined. Bump `CART_STORE_PERSIST_VERSION` alongside a
433
+ `migrate` option if the persisted shape ever changes incompatibly — that
434
+ also mints a new singleton key, so a stale old-shape instance kept alive
435
+ across a dev HMR reload can never satisfy the new one. `__resetCartStoreForTests()`
436
+ clears the singleton slot for a test that needs to prove something about the
437
+ mechanism itself; an ordinary test should keep resetting STATE via
438
+ `useCartStore.setState(...)` in `beforeEach` instead.
439
+
440
+ ### The hydration gotcha (REB-837)
441
+
442
+ `CartProvider` rehydrates AFTER mount (the `skipHydration` contract above),
443
+ so on every page load with a non-empty persisted cart, `itemCount` jumps
444
+ `0 -> N` the instant hydration finishes — observed in production on the
445
+ Dermamedicin SITE as `CartIcon`'s badge "bumping" on every single page load,
446
+ not just on a real add.
447
+
448
+ `useCart()` exposes `hydrated: boolean` for exactly this (`false` on the
449
+ server and on the very first client render, `true` once
450
+ `persist.onFinishHydration` fires — built with `useSyncExternalStore`, server
451
+ snapshot `false`). Pass it straight through to `CartIcon`'s `bumpOnAdd`:
452
+
453
+ ```tsx
454
+ const { itemCount, hydrated } = useCart()
455
+ <CartIcon itemCount={itemCount} href='/shop/cart' hydrated={hydrated} bumpOnAdd />
456
+ ```
457
+
458
+ `CartIcon` compares `itemCount` AND `hydrated` against their own previous
459
+ values — a bump only fires when the cart was ALREADY hydrated on the
460
+ previous render and `itemCount` increased since. The simultaneous
461
+ `false -> true` / `0 -> N` rehydration transition fails that first
462
+ condition; a real add afterward (hydrated already `true` on both sides)
463
+ passes it. A standalone `useCartHydrated()` is also exported for a SITE that
464
+ only needs the flag.
465
+
466
+ ### `addItem`'s return value (REB-837)
467
+
468
+ `addItem` (both on `useCartStore` and via `useCart()`) returns
469
+ `{ status: 'added' | 'clamped' | 'refused', quantity: number }` — `quantity`
470
+ is the RESULTING line quantity (`0` when refused and no line exists).
471
+ Existing callers that ignore the return value keep working unchanged.
472
+
473
+ - `'added'` — the full requested quantity was applied.
474
+ - `'clamped'` — applied, but capped below what was requested by `maxStock`
475
+ (the line still GREW).
476
+ - `'refused'` — nothing was added: an invalid quantity, the product is out of
477
+ stock, or the line was already at `maxStock` (REB-839 — this used to come
478
+ back as `'clamped'`). `quantity` is still the RESULTING line quantity, which
479
+ may be lower than before if fresher stock data reconciled the line down.
480
+
481
+ Use this to drive an `AddToCartButton`/`AddedToCartToast` pair's copy — see
482
+ `@reblu/site-blocks`'s README "Add-to-cart feedback" for the full recipe.
483
+
484
+ ### `useShopOrderStatus` — the order-confirmation flow (REB-837)
485
+
486
+ ```tsx
487
+ 'use client'
488
+ import { useShopOrderStatus } from '@reblu/site-next/shop'
489
+ import { OrderConfirmation } from '@reblu/site-blocks'
490
+ import { useSearchParams } from 'next/navigation'
491
+
492
+ export default function OrderConfirmationPage() {
493
+ const sessionId = useSearchParams().get('session_id')
494
+ const { status, order, recheck } = useShopOrderStatus(sessionId)
495
+
496
+ return (
497
+ <OrderConfirmation
498
+ status={status}
499
+ order={order}
500
+ sessionId={sessionId ?? undefined}
501
+ cartHref='/shop/cart'
502
+ onRecheck={recheck}
503
+ />
504
+ )
505
+ }
506
+ ```
507
+
508
+ Polls `GET /api/shop/order/{sessionId}` — the SITE's OWN Route Handler built
509
+ from `createShopOrderRoute`, never base-api directly — via `fetch` with an
510
+ `AbortController`, for a configurable window (`timeoutMs`, default 30000ms)
511
+ with backoff (`intervalMs` × `backoffFactor` per attempt, capped at
512
+ `maxIntervalMs`). Returns one of `loading | paid | pending_timeout | failed |
513
+ refunded | partially_refunded | not_found | error`, mapped from
514
+ `ShopOrder.paymentStatus` (`PENDING|PAID|FAILED|REFUNDED|PARTIALLY_REFUNDED`)
515
+ and the route's own 404. `OrderConfirmation` accepts every one of these
516
+ statuses directly — `loading`/`paid` are drop-in aliases for its original
517
+ `polling`/`found` states, so existing callers on the old 3-state API are
518
+ unaffected.
519
+
520
+ Clears the cart (`@reblu/site-next/cart`'s store, imperatively — not
521
+ `useCart()`, since this runs outside a component body) **ONLY on `paid`**.
522
+ `recheck()` restarts the poll loop from `loading` — wire it to
523
+ `OrderConfirmation`'s `onRecheck`, which renders it as the "Volver a
524
+ comprobar" button on `pending_timeout`/`error`.
525
+
526
+ Pass `buildUrl` to point at a non-default order route path; everything else
527
+ (`timeoutMs`/`intervalMs`/`backoffFactor`/`maxIntervalMs`/`requestTimeoutMs`)
528
+ tunes the polling window. Checkout is card-only today, so a `PENDING` order
529
+ past the window is expected to be transient — see REB-836 for the
530
+ redirect-origin hardening this flow sits behind.
531
+
532
+ `timeoutMs` (default 30000ms) bounds the WHOLE polling window via its own
533
+ independent timer — it fires `pending_timeout` even if a single request
534
+ hangs forever, never relying on that request ever settling. `requestTimeoutMs`
535
+ (default 8000ms) is the separate PER-REQUEST timeout: one slow/hung request
536
+ past it is aborted and retried with backoff (transient, not fatal); only the
537
+ window elapsing is terminal.
538
+
539
+ ### Standard success/cancel URLs (REB-837)
540
+
541
+ `createShopCheckoutRoute` builds `successUrl`/`cancelUrl` for you when the
542
+ posted body doesn't already include them — an EXPLICIT value on the body
543
+ always wins, so a SITE that already builds its own is completely unaffected:
544
+
545
+ ```ts
546
+ // app/api/shop/checkout/route.ts — defaults to /cart?pago=ok&session_id={CHECKOUT_SESSION_ID}
547
+ // and /cart?pago=cancelado
548
+ export const POST = createShopCheckoutRoute()
549
+
550
+ // Custom cart path — reshapes BOTH defaults:
551
+ export const POST = createShopCheckoutRoute({ cartPath: '/tienda/carrito' })
552
+
553
+ // Fully custom paths:
554
+ export const POST = createShopCheckoutRoute({
555
+ successPath: '/checkout/gracias?session_id={CHECKOUT_SESSION_ID}',
556
+ cancelPath: '/checkout/cancelado',
557
+ })
558
+ ```
559
+
560
+ Built absolute from the REQUEST's own origin (`request.nextUrl.origin`) —
561
+ never a caller-supplied value, so the built URL can never point off-SITE.
562
+ The `{CHECKOUT_SESSION_ID}` placeholder is preserved verbatim; Stripe
563
+ substitutes it at redirect time.
564
+
565
+ `useCheckoutReturn()` (client hook, `./shop`) parses the return query on the
566
+ cart page:
567
+
568
+ ```tsx
569
+ 'use client'
570
+ import { useCheckoutReturn } from '@reblu/site-next/shop'
571
+ import { CartSummary } from '@reblu/site-blocks'
572
+
573
+ function CartPage() {
574
+ const { status } = useCheckoutReturn() // 'ok' | 'cancelled' | null
575
+ // ...
576
+ return <CartSummary {...summaryProps} notice={status === 'cancelled'} />
577
+ }
578
+ ```
579
+
580
+ `CartSummary`/`CartLineList`'s `notice` prop (`@reblu/site-blocks`) renders
581
+ the default «Pago cancelado: tu carrito sigue aquí» copy when `true`, or any
582
+ custom `ReactNode` — see that package's README.
583
+
395
584
  ## Structured data (JSON-LD)
396
585
 
397
586
  `@reblu/site-next/json-ld` (REB-622, server-only, dependency-free) renders one
package/dist/cart.cjs CHANGED
@@ -22,8 +22,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  var cart_exports = {};
23
23
  __export(cart_exports, {
24
24
  CART_STORAGE_KEY: () => CART_STORAGE_KEY,
25
+ CART_STORE_PERSIST_VERSION: () => CART_STORE_PERSIST_VERSION,
25
26
  CartProvider: () => CartProvider,
27
+ __resetCartStoreForTests: () => __resetCartStoreForTests,
26
28
  useCart: () => useCart,
29
+ useCartHydrated: () => useCartHydrated,
27
30
  useCartStore: () => useCartStore
28
31
  });
29
32
  module.exports = __toCommonJS(cart_exports);
@@ -35,6 +38,7 @@ var import_react = require("react");
35
38
  var import_zustand = require("zustand");
36
39
  var import_middleware = require("zustand/middleware");
37
40
  var CART_STORAGE_KEY = "shop-cart";
41
+ var CART_STORE_PERSIST_VERSION = 0;
38
42
  function isSameItem(a, productId, variantId) {
39
43
  return a.productId === productId && (a.variantId ?? null) === (variantId ?? null);
40
44
  }
@@ -47,65 +51,87 @@ function normalizeQuantity(quantity, maxStock) {
47
51
  if (maxTruncated < 1) return 0;
48
52
  return Math.min(truncated, maxTruncated);
49
53
  }
50
- var useCartStore = (0, import_zustand.create)()(
51
- (0, import_middleware.persist)(
52
- (set, get) => ({
53
- items: [],
54
- addItem: (item) => {
55
- set((state) => {
56
- const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId));
57
- if (existing) {
58
- if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {
54
+ function createCartStore() {
55
+ return (0, import_zustand.create)()(
56
+ (0, import_middleware.persist)(
57
+ (set, get) => ({
58
+ items: [],
59
+ addItem: (item) => {
60
+ let outcome = { status: "refused", quantity: 0 };
61
+ set((state) => {
62
+ const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId));
63
+ if (existing) {
64
+ if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {
65
+ outcome = { status: "refused", quantity: 0 };
66
+ return {
67
+ items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId))
68
+ };
69
+ }
70
+ const requestedQty = existing.quantity + item.quantity;
71
+ const newQty = normalizeQuantity(requestedQty, item.maxStock);
72
+ if (newQty < 1) {
73
+ outcome = { status: "refused", quantity: existing.quantity };
74
+ return {};
75
+ }
76
+ outcome = {
77
+ status: newQty <= existing.quantity ? "refused" : newQty < requestedQty ? "clamped" : "added",
78
+ quantity: newQty
79
+ };
59
80
  return {
60
- items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId))
81
+ items: state.items.map(
82
+ (i) => isSameItem(i, item.productId, item.variantId) ? { ...i, quantity: newQty, maxStock: item.maxStock } : i
83
+ )
61
84
  };
62
85
  }
63
- const newQty = normalizeQuantity(existing.quantity + item.quantity, item.maxStock);
64
- if (newQty < 1) {
86
+ const qty = normalizeQuantity(item.quantity, item.maxStock);
87
+ if (qty < 1) {
88
+ outcome = { status: "refused", quantity: 0 };
65
89
  return {};
66
90
  }
67
- return {
68
- items: state.items.map(
69
- (i) => isSameItem(i, item.productId, item.variantId) ? { ...i, quantity: newQty, maxStock: item.maxStock } : i
70
- )
71
- };
72
- }
73
- const qty = normalizeQuantity(item.quantity, item.maxStock);
74
- if (qty < 1) {
75
- return {};
91
+ outcome = { status: qty < item.quantity ? "clamped" : "added", quantity: qty };
92
+ return { items: [...state.items, { ...item, quantity: qty }] };
93
+ });
94
+ return outcome;
95
+ },
96
+ removeItem: (productId, variantId) => {
97
+ set((state) => ({
98
+ items: state.items.filter((i) => !isSameItem(i, productId, variantId))
99
+ }));
100
+ },
101
+ updateQuantity: (productId, variantId, quantity) => {
102
+ const existing = get().items.find((i) => isSameItem(i, productId, variantId));
103
+ if (!existing) return;
104
+ const newQty = normalizeQuantity(quantity, existing.maxStock);
105
+ if (newQty < 1) {
106
+ get().removeItem(productId, variantId);
107
+ return;
76
108
  }
77
- return { items: [...state.items, { ...item, quantity: qty }] };
78
- });
79
- },
80
- removeItem: (productId, variantId) => {
81
- set((state) => ({
82
- items: state.items.filter((i) => !isSameItem(i, productId, variantId))
83
- }));
84
- },
85
- updateQuantity: (productId, variantId, quantity) => {
86
- const existing = get().items.find((i) => isSameItem(i, productId, variantId));
87
- if (!existing) return;
88
- const newQty = normalizeQuantity(quantity, existing.maxStock);
89
- if (newQty < 1) {
90
- get().removeItem(productId, variantId);
91
- return;
92
- }
93
- set((state) => ({
94
- items: state.items.map(
95
- (i) => isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i
96
- )
97
- }));
98
- },
99
- clearCart: () => set({ items: [] }),
100
- getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),
101
- getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0)
102
- }),
103
- {
104
- name: CART_STORAGE_KEY,
105
- skipHydration: true
106
- }
107
- )
109
+ set((state) => ({
110
+ items: state.items.map(
111
+ (i) => isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i
112
+ )
113
+ }));
114
+ },
115
+ clearCart: () => set({ items: [] }),
116
+ getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),
117
+ getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0)
118
+ }),
119
+ {
120
+ name: CART_STORAGE_KEY,
121
+ version: CART_STORE_PERSIST_VERSION,
122
+ skipHydration: true
123
+ }
124
+ )
125
+ );
126
+ }
127
+ var CART_STORE_GLOBAL_KEY = /* @__PURE__ */ Symbol.for(
128
+ `@reblu/site-next/cart-store@v${CART_STORE_PERSIST_VERSION}`
108
129
  );
130
+ var globalForCartStore = globalThis;
131
+ var useCartStore = globalForCartStore[CART_STORE_GLOBAL_KEY] ?? (globalForCartStore[CART_STORE_GLOBAL_KEY] = createCartStore());
132
+ function __resetCartStoreForTests() {
133
+ delete globalForCartStore[CART_STORE_GLOBAL_KEY];
134
+ }
109
135
 
110
136
  // src/cart/provider.tsx
111
137
  var import_jsx_runtime = require("react/jsx-runtime");
@@ -117,21 +143,38 @@ function CartProvider({ children }) {
117
143
  }
118
144
 
119
145
  // src/cart/use-cart.ts
146
+ var import_react2 = require("react");
147
+ function subscribeHydration(onStoreChange) {
148
+ return useCartStore.persist.onFinishHydration(onStoreChange);
149
+ }
150
+ function getHydratedSnapshot() {
151
+ return useCartStore.persist.hasHydrated();
152
+ }
153
+ function getServerHydratedSnapshot() {
154
+ return false;
155
+ }
156
+ function useCartHydrated() {
157
+ return (0, import_react2.useSyncExternalStore)(subscribeHydration, getHydratedSnapshot, getServerHydratedSnapshot);
158
+ }
120
159
  function useCart() {
121
160
  const items = useCartStore((s) => s.items);
122
161
  const addItem = useCartStore((s) => s.addItem);
123
162
  const removeItem = useCartStore((s) => s.removeItem);
124
163
  const updateQuantity = useCartStore((s) => s.updateQuantity);
125
164
  const clearCart = useCartStore((s) => s.clearCart);
165
+ const hydrated = useCartHydrated();
126
166
  const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
127
167
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
128
- return { items, total, itemCount, addItem, removeItem, updateQuantity, clearCart };
168
+ return { items, total, itemCount, hydrated, addItem, removeItem, updateQuantity, clearCart };
129
169
  }
130
170
  // Annotate the CommonJS export names for ESM import in node:
131
171
  0 && (module.exports = {
132
172
  CART_STORAGE_KEY,
173
+ CART_STORE_PERSIST_VERSION,
133
174
  CartProvider,
175
+ __resetCartStoreForTests,
134
176
  useCart,
177
+ useCartHydrated,
135
178
  useCartStore
136
179
  });
137
180
  //# sourceMappingURL=cart.cjs.map
package/dist/cart.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/cart/index.ts","../src/cart/provider.tsx","../src/cart/store.ts","../src/cart/use-cart.ts"],"sourcesContent":["'use client'\n\n/**\n * `@reblu/site-next/cart` (REB-829) — the SDK's cart client surface. This\n * barrel MUST begin with its OWN leading `'use client'` directive — same R4\n * rule as `./auth`/`./academy`: tsup only preserves a directive prologue\n * declared by the entry file itself (see `tsup.config.ts`).\n *\n * Mount `CartProvider` once near the root layout, then read/mutate the cart\n * from any Client Component via `useCart()` (derived `total`/`itemCount`) or\n * the raw `useCartStore` (per-field selectors, zustand-idiomatic). Both\n * share the SAME store instance and the SAME `shop-cart` localStorage key a\n * SITE migrating off a hand-rolled store already used.\n */\nexport { CartProvider } from './provider'\nexport { useCart, type UseCartResult } from './use-cart'\nexport { useCartStore, CART_STORAGE_KEY, type CartItem, type CartStore } from './store'\n","'use client'\n\nimport { useEffect } from 'react'\nimport { useCartStore } from './store'\n\n/**\n * `CartProvider` — mount this once near the root layout (inside a Client\n * Component boundary). It owns the ONE explicit-hydration call\n * `useCartStore.persist.rehydrate()` requires (`store.ts`'s `skipHydration`\n * doc) — a SITE that forgets to mount it gets an always-empty cart that never\n * reads back whatever a previous visit (or page reload) persisted.\n */\nexport function CartProvider({ children }: { children: React.ReactNode }) {\n useEffect(() => {\n useCartStore.persist.rehydrate()\n }, [])\n\n return <>{children}</>\n}\n","import { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\n/**\n * `useCartStore` — the SDK's cart state (REB-829). Ported verbatim (same\n * shape, same quantity-clamping rules) from demo-site's local\n * `lib/store/cart-store.ts`, which every SITE was otherwise expected to\n * hand-copy. `CART_STORAGE_KEY` stays exactly `'shop-cart'` on purpose: a\n * SITE migrating from the local copy keeps its visitors' existing carts.\n *\n * `skipHydration: true` (zustand persist) is load-bearing for SSR: without\n * it, zustand's persist middleware reads `localStorage` synchronously during\n * module evaluation, which throws (or silently desyncs) on the server. The\n * `CartProvider` (`./provider`) calls `useCartStore.persist.rehydrate()`\n * inside a `useEffect`, so hydration only ever happens client-side, after\n * mount — the same explicit-hydration contract the SITE README documents.\n */\nexport const CART_STORAGE_KEY = 'shop-cart'\n\nexport interface CartItem {\n productId: string\n variantId?: string | null\n name: string\n variantName?: string\n /** Integer cents — every Reblu shop price travels as cents, never a float. */\n unitPrice: number\n quantity: number\n imageUrl?: string\n /** `null` means untracked/unlimited stock — never clamp against it. */\n maxStock: number | null\n /** Basis points, e.g. `2100` = 21%. `null`/`undefined` = unknown or exempt. */\n taxRateBps?: number | null\n taxRateName?: string | null\n}\n\nexport interface CartStore {\n items: CartItem[]\n addItem: (item: CartItem) => void\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n getTotal: () => number\n getItemCount: () => number\n}\n\nfunction isSameItem(a: CartItem, productId: string, variantId?: string | null): boolean {\n return a.productId === productId && (a.variantId ?? null) === (variantId ?? null)\n}\n\n/**\n * REB-829 review finding: neither `addItem` nor `updateQuantity` validated\n * `quantity` — `0`, a negative number, `NaN` (e.g. a stray `parseInt('')`\n * upstream), a non-integer, or `maxStock` itself being `0` (out of stock)\n * all used to leave an invalid line sitting in the cart, silently reaching\n * checkout.\n *\n * Normalizes to a safe POSITIVE INTEGER clamped to `maxStock`. Returns `0`\n * to signal \"not a valid positive quantity\" — callers decide what that\n * means for them: `addItem` ignores the call (never creates/corrupts a\n * line), `updateQuantity` removes the line (same as its pre-existing\n * `quantity <= 0` → remove contract, just now reached by more inputs).\n *\n * `Math.trunc` rounds toward zero (not `Math.floor`, which would round a\n * negative input further negative before the `< 1` check below ever saw it\n * — not a behavioural difference here since any negative result is\n * rejected either way, but `trunc` is the operation that actually means\n * \"drop the fractional part\").\n */\nfunction normalizeQuantity(quantity: number, maxStock: number | null): number {\n if (!Number.isFinite(quantity)) return 0\n const truncated = Math.trunc(quantity)\n if (truncated < 1) return 0\n if (maxStock === null) return truncated\n const maxTruncated = Math.trunc(maxStock)\n if (maxTruncated < 1) return 0\n return Math.min(truncated, maxTruncated)\n}\n\nexport const useCartStore = create<CartStore>()(\n persist(\n (set, get) => ({\n items: [],\n\n addItem: (item) => {\n set((state) => {\n const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId))\n\n if (existing) {\n // REB-829 review follow-up (a): incoming maxStock is the\n // FRESHEST stock signal available — if it says 0, the product is\n // confirmed out of stock right now, so the existing line is\n // removed outright (same contract as updateQuantity's own\n // \"quantity resolves to <1 → remove\"), not merely left alone.\n if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {\n return {\n items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId)),\n }\n }\n\n const newQty = normalizeQuantity(existing.quantity + item.quantity, item.maxStock)\n if (newQty < 1) {\n // Invalid delta (NaN/non-positive quantity) — leave the\n // existing, previously-valid line untouched rather than\n // destroying good data because of one bad call.\n return {}\n }\n\n // Refresh maxStock with the incoming value on every successful\n // merge — the stale-maxStock finding: a stored line used to keep\n // its ORIGINAL maxStock forever, so a later updateQuantity call\n // clamped against stock data that could be long out of date.\n return {\n items: state.items.map((i) =>\n isSameItem(i, item.productId, item.variantId)\n ? { ...i, quantity: newQty, maxStock: item.maxStock }\n : i,\n ),\n }\n }\n\n const qty = normalizeQuantity(item.quantity, item.maxStock)\n if (qty < 1) {\n // Invalid quantity, or maxStock 0 (out of stock) — never create\n // a line nobody could actually buy.\n return {}\n }\n\n return { items: [...state.items, { ...item, quantity: qty }] }\n })\n },\n\n removeItem: (productId, variantId) => {\n set((state) => ({\n items: state.items.filter((i) => !isSameItem(i, productId, variantId)),\n }))\n },\n\n updateQuantity: (productId, variantId, quantity) => {\n const existing = get().items.find((i) => isSameItem(i, productId, variantId))\n if (!existing) return\n\n const newQty = normalizeQuantity(quantity, existing.maxStock)\n if (newQty < 1) {\n get().removeItem(productId, variantId)\n return\n }\n\n set((state) => ({\n items: state.items.map((i) =>\n isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i,\n ),\n }))\n },\n\n clearCart: () => set({ items: [] }),\n\n getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),\n\n getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0),\n }),\n {\n name: CART_STORAGE_KEY,\n skipHydration: true,\n },\n ),\n)\n","'use client'\n\nimport { useCartStore, type CartItem } from './store'\n\nexport interface UseCartResult {\n items: CartItem[]\n /** Sum of every line's `unitPrice * quantity`, in integer cents. */\n total: number\n /** Sum of every line's `quantity`. */\n itemCount: number\n addItem: (item: CartItem) => void\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n}\n\n/**\n * `useCart` — the ergonomic entry point for a SITE's cart UI: a single\n * selector call that re-renders only on `items` changes, with `total`/\n * `itemCount` derived inline (cheap — a plain `reduce` over a typically-small\n * array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`\n * selectors, which are stable function references and would NOT trigger a\n * re-render on their own. A SITE that wants raw store access (existing\n * per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still\n * import `useCartStore` directly — this hook doesn't replace it.\n */\nexport function useCart(): UseCartResult {\n const items = useCartStore((s) => s.items)\n const addItem = useCartStore((s) => s.addItem)\n const removeItem = useCartStore((s) => s.removeItem)\n const updateQuantity = useCartStore((s) => s.updateQuantity)\n const clearCart = useCartStore((s) => s.clearCart)\n\n const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0)\n const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)\n\n return { items, total, itemCount, addItem, removeItem, updateQuantity, clearCart }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/cart/index.ts","../src/cart/provider.tsx","../src/cart/store.ts","../src/cart/use-cart.ts"],"sourcesContent":["'use client'\n\n/**\n * `@reblu/site-next/cart` (REB-829) — the SDK's cart client surface. This\n * barrel MUST begin with its OWN leading `'use client'` directive — same R4\n * rule as `./auth`/`./academy`: tsup only preserves a directive prologue\n * declared by the entry file itself (see `tsup.config.ts`).\n *\n * Mount `CartProvider` once near the root layout, then read/mutate the cart\n * from any Client Component via `useCart()` (derived `total`/`itemCount`) or\n * the raw `useCartStore` (per-field selectors, zustand-idiomatic). Both\n * share the SAME store instance and the SAME `shop-cart` localStorage key a\n * SITE migrating off a hand-rolled store already used.\n */\nexport { CartProvider } from './provider'\nexport { useCart, useCartHydrated, type UseCartResult } from './use-cart'\nexport {\n useCartStore,\n CART_STORAGE_KEY,\n CART_STORE_PERSIST_VERSION,\n __resetCartStoreForTests,\n type CartItem,\n type CartStore,\n type AddItemOutcome,\n type AddItemStatus,\n} from './store'\n","'use client'\n\nimport { useEffect } from 'react'\nimport { useCartStore } from './store'\n\n/**\n * `CartProvider` — mount this once near the root layout (inside a Client\n * Component boundary). It owns the ONE explicit-hydration call\n * `useCartStore.persist.rehydrate()` requires (`store.ts`'s `skipHydration`\n * doc) — a SITE that forgets to mount it gets an always-empty cart that never\n * reads back whatever a previous visit (or page reload) persisted.\n */\nexport function CartProvider({ children }: { children: React.ReactNode }) {\n useEffect(() => {\n useCartStore.persist.rehydrate()\n }, [])\n\n return <>{children}</>\n}\n","import { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\n/**\n * `useCartStore` — the SDK's cart state (REB-829). Ported verbatim (same\n * shape, same quantity-clamping rules) from demo-site's local\n * `lib/store/cart-store.ts`, which every SITE was otherwise expected to\n * hand-copy. `CART_STORAGE_KEY` stays exactly `'shop-cart'` on purpose: a\n * SITE migrating from the local copy keeps its visitors' existing carts.\n *\n * `skipHydration: true` (zustand persist) is load-bearing for SSR: without\n * it, zustand's persist middleware reads `localStorage` synchronously during\n * module evaluation, which throws (or silently desyncs) on the server. The\n * `CartProvider` (`./provider`) calls `useCartStore.persist.rehydrate()`\n * inside a `useEffect`, so hydration only ever happens client-side, after\n * mount — the same explicit-hydration contract the SITE README documents.\n */\nexport const CART_STORAGE_KEY = 'shop-cart'\n\n/**\n * The zustand `persist` schema version for the cart's PERSISTED shape (the\n * `CartItem[]` written to `localStorage` under `CART_STORAGE_KEY`) — bump\n * this (with a `migrate` option) if that shape ever changes incompatibly.\n * Also folded into {@link CART_STORE_GLOBAL_KEY} (REB-837 review fix): a\n * future incompatible shape change should mint a NEW global singleton\n * rather than let a stale, already-evaluated old-shape instance (e.g. kept\n * alive across an HMR reload in dev) silently satisfy the new one.\n */\nexport const CART_STORE_PERSIST_VERSION = 0\n\nexport interface CartItem {\n productId: string\n variantId?: string | null\n name: string\n variantName?: string\n /** Integer cents — every Reblu shop price travels as cents, never a float. */\n unitPrice: number\n quantity: number\n imageUrl?: string\n /** `null` means untracked/unlimited stock — never clamp against it. */\n maxStock: number | null\n /** Basis points, e.g. `2100` = 21%. `null`/`undefined` = unknown or exempt. */\n taxRateBps?: number | null\n taxRateName?: string | null\n}\n\n/**\n * Outcome of `addItem` (REB-837): a SITE's add-to-cart feedback (toast,\n * `AddToCartButton`'s built-in confirmed/error state) needs to know what\n * actually happened, not just fire-and-forget — `'clamped'` only differs\n * from `'added'` in the RESULTING quantity reaching less than what was\n * requested (capped by `maxStock`) while the line still grew; `'refused'`\n * means nothing was added (invalid quantity, out of stock, or the line was\n * already at `maxStock` — REB-839).\n */\nexport type AddItemStatus = 'added' | 'clamped' | 'refused'\n\nexport interface AddItemOutcome {\n status: AddItemStatus\n /** The resulting line quantity after the call (`0` when refused and no line exists). */\n quantity: number\n}\n\nexport interface CartStore {\n items: CartItem[]\n addItem: (item: CartItem) => AddItemOutcome\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n getTotal: () => number\n getItemCount: () => number\n}\n\nfunction isSameItem(a: CartItem, productId: string, variantId?: string | null): boolean {\n return a.productId === productId && (a.variantId ?? null) === (variantId ?? null)\n}\n\n/**\n * REB-829 review finding: neither `addItem` nor `updateQuantity` validated\n * `quantity` — `0`, a negative number, `NaN` (e.g. a stray `parseInt('')`\n * upstream), a non-integer, or `maxStock` itself being `0` (out of stock)\n * all used to leave an invalid line sitting in the cart, silently reaching\n * checkout.\n *\n * Normalizes to a safe POSITIVE INTEGER clamped to `maxStock`. Returns `0`\n * to signal \"not a valid positive quantity\" — callers decide what that\n * means for them: `addItem` ignores the call (never creates/corrupts a\n * line), `updateQuantity` removes the line (same as its pre-existing\n * `quantity <= 0` → remove contract, just now reached by more inputs).\n *\n * `Math.trunc` rounds toward zero (not `Math.floor`, which would round a\n * negative input further negative before the `< 1` check below ever saw it\n * — not a behavioural difference here since any negative result is\n * rejected either way, but `trunc` is the operation that actually means\n * \"drop the fractional part\").\n */\nfunction normalizeQuantity(quantity: number, maxStock: number | null): number {\n if (!Number.isFinite(quantity)) return 0\n const truncated = Math.trunc(quantity)\n if (truncated < 1) return 0\n if (maxStock === null) return truncated\n const maxTruncated = Math.trunc(maxStock)\n if (maxTruncated < 1) return 0\n return Math.min(truncated, maxTruncated)\n}\n\n/**\n * REB-837 cross-entry singleton guard — the mutation that caught it: a\n * single test rendering `useShopOrderStatus` (the `./shop` tsup entry) and\n * asserting on `useCartStore` (the `./cart` entry) saw the order resolve to\n * `paid` but the cart stay non-empty. Root cause: `splitting: false` (R4,\n * `tsup.config.ts`) bundles each entry standalone, so a plain relative\n * import of this file from `./shop` INLINES a SEPARATE copy of this\n * `create(...)` call — a second, disconnected store instance with its own\n * state, not the one `@reblu/site-next/cart` consumers read. `clearCart()`\n * from inside the hook silently cleared the WRONG store.\n *\n * `globalThis` keyed by a `Symbol.for(...)` (same registry entry regardless\n * of which bundle's copy of this module evaluates it first) makes every\n * entry that imports this file — however many times esbuild inlines it —\n * resolve to the SAME store. Verified by mutation: removing this guard\n * (reverting to a bare `export const useCartStore = create(...)`) turns\n * `use-shop-order-status.test.ts`'s cart-clearing assertions red again once\n * the hook is exercised through its OWN entry rather than imported directly\n * within `cart`'s own test file (which never saw the bug, since it only\n * ever touches ONE copy of this module).\n */\nfunction createCartStore() {\n return create<CartStore>()(\n persist(\n (set, get) => ({\n items: [],\n\n addItem: (item) => {\n // REB-837: `set`'s updater can't return a value to the caller, so the\n // outcome is captured in this closure variable and returned after.\n let outcome: AddItemOutcome = { status: 'refused', quantity: 0 }\n\n set((state) => {\n const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId))\n\n if (existing) {\n // REB-829 review follow-up (a): incoming maxStock is the\n // FRESHEST stock signal available — if it says 0, the product is\n // confirmed out of stock right now, so the existing line is\n // removed outright (same contract as updateQuantity's own\n // \"quantity resolves to <1 → remove\"), not merely left alone.\n if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {\n outcome = { status: 'refused', quantity: 0 }\n return {\n items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId)),\n }\n }\n\n const requestedQty = existing.quantity + item.quantity\n const newQty = normalizeQuantity(requestedQty, item.maxStock)\n if (newQty < 1) {\n // Invalid delta (NaN/non-positive quantity) — leave the\n // existing, previously-valid line untouched rather than\n // destroying good data because of one bad call.\n outcome = { status: 'refused', quantity: existing.quantity }\n return {}\n }\n\n // REB-839: nothing was gained when stock caps the line at (or,\n // with fresher stock, below) what it already held — report that\n // as 'refused', never as a partial-success 'clamped'. The line is\n // still reconciled below to the fresher maxStock.\n outcome = {\n status:\n newQty <= existing.quantity\n ? 'refused'\n : newQty < requestedQty\n ? 'clamped'\n : 'added',\n quantity: newQty,\n }\n\n // Refresh maxStock with the incoming value on every successful\n // merge — the stale-maxStock finding: a stored line used to keep\n // its ORIGINAL maxStock forever, so a later updateQuantity call\n // clamped against stock data that could be long out of date.\n return {\n items: state.items.map((i) =>\n isSameItem(i, item.productId, item.variantId)\n ? { ...i, quantity: newQty, maxStock: item.maxStock }\n : i,\n ),\n }\n }\n\n const qty = normalizeQuantity(item.quantity, item.maxStock)\n if (qty < 1) {\n // Invalid quantity, or maxStock 0 (out of stock) — never create\n // a line nobody could actually buy.\n outcome = { status: 'refused', quantity: 0 }\n return {}\n }\n\n outcome = { status: qty < item.quantity ? 'clamped' : 'added', quantity: qty }\n return { items: [...state.items, { ...item, quantity: qty }] }\n })\n\n return outcome\n },\n\n removeItem: (productId, variantId) => {\n set((state) => ({\n items: state.items.filter((i) => !isSameItem(i, productId, variantId)),\n }))\n },\n\n updateQuantity: (productId, variantId, quantity) => {\n const existing = get().items.find((i) => isSameItem(i, productId, variantId))\n if (!existing) return\n\n const newQty = normalizeQuantity(quantity, existing.maxStock)\n if (newQty < 1) {\n get().removeItem(productId, variantId)\n return\n }\n\n set((state) => ({\n items: state.items.map((i) =>\n isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i,\n ),\n }))\n },\n\n clearCart: () => set({ items: [] }),\n\n getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),\n\n getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0),\n }),\n {\n name: CART_STORAGE_KEY,\n version: CART_STORE_PERSIST_VERSION,\n skipHydration: true,\n },\n ),\n )\n}\n\ntype CartStoreApi = ReturnType<typeof createCartStore>\n\n/**\n * Versioned (REB-837 review fix) with {@link CART_STORE_PERSIST_VERSION} —\n * an incompatible shape change bumps that version, which mints a NEW\n * `Symbol.for(...)` registry key, so a stale old-shape singleton (e.g. kept\n * alive across an HMR reload in dev before a code change takes effect)\n * never satisfies the new one.\n */\nconst CART_STORE_GLOBAL_KEY = Symbol.for(\n `@reblu/site-next/cart-store@v${CART_STORE_PERSIST_VERSION}`,\n)\n\ninterface GlobalWithCartStore {\n [CART_STORE_GLOBAL_KEY]?: CartStoreApi\n}\n\nconst globalForCartStore = globalThis as typeof globalThis & GlobalWithCartStore\n\nexport const useCartStore: CartStoreApi =\n globalForCartStore[CART_STORE_GLOBAL_KEY] ??\n (globalForCartStore[CART_STORE_GLOBAL_KEY] = createCartStore())\n\n/**\n * TEST-ONLY (REB-837 review fix). Clears the `globalThis`-keyed slot so the\n * NEXT access to `useCartStore` — in THIS module instance — creates a fresh\n * store. The singleton is intentionally shared process-wide; most tests\n * should keep resetting STATE via `useCartStore.setState(...)` in\n * `beforeEach`, not the instance itself. Reach for this only to prove\n * something about the singleton mechanism (e.g. the cross-entry test in\n * `tests/cart-store-cross-entry-singleton.test.ts`) — it does not and\n * cannot affect an ALREADY-evaluated copy of this module inlined into a\n * different tsup entry's bundle (each such copy owns its own `const\n * globalForCartStore`/`CART_STORE_GLOBAL_KEY` closed over at that copy's\n * own module-evaluation time, same as any other ES module binding).\n */\nexport function __resetCartStoreForTests(): void {\n delete globalForCartStore[CART_STORE_GLOBAL_KEY]\n}\n","'use client'\n\nimport { useSyncExternalStore } from 'react'\n\nimport { useCartStore, type AddItemOutcome, type CartItem } from './store'\n\nexport interface UseCartResult {\n items: CartItem[]\n /** Sum of every line's `unitPrice * quantity`, in integer cents. */\n total: number\n /** Sum of every line's `quantity`. */\n itemCount: number\n /**\n * Whether `CartProvider`'s `persist.rehydrate()` has finished (REB-837).\n * `false` on the server and on the very first client render — `items`\n * (and therefore `itemCount`) jumps from `0` to whatever was persisted the\n * instant hydration finishes, which is NOT a real \"item added\" event. A\n * consumer driving `CartIcon`'s `bumpOnAdd` MUST gate the bump on this\n * flag (see `CartIcon`'s own guard) or every page load with a non-empty\n * cart bumps the badge once for free.\n */\n hydrated: boolean\n addItem: (item: CartItem) => AddItemOutcome\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n}\n\nfunction subscribeHydration(onStoreChange: () => void): () => void {\n return useCartStore.persist.onFinishHydration(onStoreChange)\n}\n\nfunction getHydratedSnapshot(): boolean {\n return useCartStore.persist.hasHydrated()\n}\n\n/** Server snapshot is always `false` — the server never reads `localStorage`. */\nfunction getServerHydratedSnapshot(): boolean {\n return false\n}\n\n/**\n * `useCartHydrated` — standalone so a SITE that only needs the flag (e.g. to\n * gate its own effects) doesn't have to subscribe to the whole cart via\n * `useCart()`.\n */\nexport function useCartHydrated(): boolean {\n return useSyncExternalStore(subscribeHydration, getHydratedSnapshot, getServerHydratedSnapshot)\n}\n\n/**\n * `useCart` — the ergonomic entry point for a SITE's cart UI: a single\n * selector call that re-renders only on `items` changes, with `total`/\n * `itemCount` derived inline (cheap — a plain `reduce` over a typically-small\n * array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`\n * selectors, which are stable function references and would NOT trigger a\n * re-render on their own. A SITE that wants raw store access (existing\n * per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still\n * import `useCartStore` directly — this hook doesn't replace it.\n */\nexport function useCart(): UseCartResult {\n const items = useCartStore((s) => s.items)\n const addItem = useCartStore((s) => s.addItem)\n const removeItem = useCartStore((s) => s.removeItem)\n const updateQuantity = useCartStore((s) => s.updateQuantity)\n const clearCart = useCartStore((s) => s.clearCart)\n const hydrated = useCartHydrated()\n\n const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0)\n const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)\n\n return { items, total, itemCount, hydrated, addItem, removeItem, updateQuantity, clearCart }\n}\n"],"mappings":"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","names":["import_react"]}