@reblu/site-next 1.14.0 → 1.15.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 +73 -0
- package/README.md +187 -1
- package/dist/cart.cjs +96 -53
- package/dist/cart.cjs.map +1 -1
- package/dist/cart.d.cts +81 -4
- package/dist/cart.d.ts +81 -4
- package/dist/cart.js +93 -53
- package/dist/cart.js.map +1 -1
- package/dist/{collection-BE5dhf-r.d.cts → collection-gAD_g03I.d.cts} +1 -1
- package/dist/{collection-BE5dhf-r.d.ts → collection-gAD_g03I.d.ts} +1 -1
- package/dist/route-handlers.cjs +8 -1
- package/dist/route-handlers.cjs.map +1 -1
- package/dist/route-handlers.d.cts +11 -0
- package/dist/route-handlers.d.ts +11 -0
- package/dist/route-handlers.js +8 -1
- package/dist/route-handlers.js.map +1 -1
- package/dist/shop-route-handlers.cjs +97 -2
- package/dist/shop-route-handlers.cjs.map +1 -1
- package/dist/shop-route-handlers.d.cts +70 -6
- package/dist/shop-route-handlers.d.ts +70 -6
- package/dist/shop-route-handlers.js +97 -2
- package/dist/shop-route-handlers.js.map +1 -1
- package/dist/shop-server.d.cts +1 -1
- package/dist/shop-server.d.ts +1 -1
- package/dist/shop.cjs +222 -2
- package/dist/shop.cjs.map +1 -1
- package/dist/shop.d.cts +72 -4
- package/dist/shop.d.ts +72 -4
- package/dist/shop.js +219 -1
- package/dist/shop.js.map +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# @reblu/site-next
|
|
2
2
|
|
|
3
|
+
## 1.15.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: `addItem` (REB-837) now returns `{ status: 'added' | 'clamped' | 'refused',
|
|
16
|
+
quantity }` instead of `void` — `quantity` is the resulting line quantity.
|
|
17
|
+
Backward compatible: existing callers that ignore the return value are
|
|
18
|
+
unaffected. `useCart()` gains `hydrated: boolean` (also exported standalone
|
|
19
|
+
as `useCartHydrated()`), built with `useSyncExternalStore` over zustand
|
|
20
|
+
persist's `onFinishHydration`/`hasHydrated` — fixes the "bump on every page
|
|
21
|
+
load" bug: `CartIcon`'s `bumpOnAdd` guard needs this to tell a real add apart
|
|
22
|
+
from the `itemCount` 0 -> N jump that `CartProvider`'s post-mount rehydration
|
|
23
|
+
causes on every page load with a non-empty persisted cart. README documents
|
|
24
|
+
the exact persisted shape (pinned by a test) and the hydration gotcha.
|
|
25
|
+
- b86a16b: Standard success/cancel URLs (REB-837). `createShopCheckoutRoute` now
|
|
26
|
+
accepts `successPath`/`cancelPath`/`cartPath` and injects `successUrl`/
|
|
27
|
+
`cancelUrl` into the posted body — absolute from the REQUEST's own origin,
|
|
28
|
+
never a caller-supplied one — when the body doesn't already carry them; an
|
|
29
|
+
explicit value on the body always wins, so this is fully backward
|
|
30
|
+
compatible. Defaults to `/cart?pago=ok&session_id={CHECKOUT_SESSION_ID}` and
|
|
31
|
+
`/cart?pago=cancelado`. New `useCheckoutReturn()` (`./shop`) parses that
|
|
32
|
+
return query. `createProxyRoute` gained a small supporting `transformBody`
|
|
33
|
+
option (generic, additive) that this feature is built on.
|
|
34
|
+
- b86a16b: Add `useShopOrderStatus(sessionId, opts?)` from `./shop` (REB-837): polls the
|
|
35
|
+
SITE's own order Route Handler with an `AbortController`, for a configurable
|
|
36
|
+
window (default 30s) with backoff, and clears the cart ONLY once the order
|
|
37
|
+
resolves to PAID. Returns `loading | paid | pending_timeout | failed |
|
|
38
|
+
refunded | partially_refunded | not_found | error`, mapped from
|
|
39
|
+
`ShopOrder.paymentStatus` and the route's own 404, with a `recheck()` to
|
|
40
|
+
restart the poll loop. Fixes a real infinite-loop bug where a caller passing
|
|
41
|
+
the natural `{ buildUrl: (id) => ... }` object literal would re-fetch
|
|
42
|
+
forever — `buildUrl` is now read through a ref, not a polling-effect
|
|
43
|
+
dependency.
|
|
44
|
+
- b86a16b: Gentle AI review follow-up (REB-837):
|
|
45
|
+
- `useShopOrderStatus` gains `requestTimeoutMs` (default 8000ms) — a single
|
|
46
|
+
hung/slow request is now aborted and retried, and `timeoutMs` (the WHOLE
|
|
47
|
+
polling window) is enforced by its own independent timer, not by checking
|
|
48
|
+
elapsed time inside `poll()`. Previously a request that never settled left
|
|
49
|
+
`status` stuck on `'loading'` forever, with no way to ever reach
|
|
50
|
+
`pending_timeout`.
|
|
51
|
+
- `useCartStore`'s `globalThis`-keyed singleton is now versioned
|
|
52
|
+
(`CART_STORE_PERSIST_VERSION`, folded into the registry key) and a new
|
|
53
|
+
`__resetCartStoreForTests()` clears the slot for tests that need to prove
|
|
54
|
+
something about the singleton mechanism itself. New test
|
|
55
|
+
(`tests/cart-store-cross-entry-singleton.test.ts`) proves `./cart` and
|
|
56
|
+
`./shop` resolve to the exact same store instance by building and loading
|
|
57
|
+
both real entries.
|
|
58
|
+
|
|
59
|
+
### Patch Changes
|
|
60
|
+
|
|
61
|
+
- b86a16b: Fix a cross-entry singleton bug in `useCartStore` (REB-837, found while
|
|
62
|
+
adopting `useShopOrderStatus` in demo-site): `splitting: false` bundles each
|
|
63
|
+
tsup entry standalone, so `./shop`'s relative import of `./cart/store`
|
|
64
|
+
inlined a SEPARATE store instance — `useShopOrderStatus`'s cart-clearing ran
|
|
65
|
+
against a disconnected store, never the one `@reblu/site-next/cart`
|
|
66
|
+
consumers actually read. `useCartStore` is now a `globalThis`-keyed
|
|
67
|
+
singleton (`Symbol.for(...)`), so every entry that imports it resolves to
|
|
68
|
+
the SAME instance regardless of how many times esbuild inlines the module.
|
|
69
|
+
No API change.
|
|
70
|
+
- Updated dependencies [21feec3]
|
|
71
|
+
- Updated dependencies [b86a16b]
|
|
72
|
+
- @reblu/site-contracts@1.6.0
|
|
73
|
+
- @reblu/site-client@5.3.0
|
|
74
|
+
- @reblu/site-ui@0.6.0
|
|
75
|
+
|
|
3
76
|
## 1.14.0
|
|
4
77
|
|
|
5
78
|
### 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,190 @@ 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
|
+
- `'refused'` — nothing changed (an invalid quantity, or the product is out
|
|
476
|
+
of stock); `quantity` reports whatever the line already held.
|
|
477
|
+
|
|
478
|
+
Use this to drive an `AddToCartButton`/`AddedToCartToast` pair's copy — see
|
|
479
|
+
`@reblu/site-blocks`'s README "Add-to-cart feedback" for the full recipe.
|
|
480
|
+
|
|
481
|
+
### `useShopOrderStatus` — the order-confirmation flow (REB-837)
|
|
482
|
+
|
|
483
|
+
```tsx
|
|
484
|
+
'use client'
|
|
485
|
+
import { useShopOrderStatus } from '@reblu/site-next/shop'
|
|
486
|
+
import { OrderConfirmation } from '@reblu/site-blocks'
|
|
487
|
+
import { useSearchParams } from 'next/navigation'
|
|
488
|
+
|
|
489
|
+
export default function OrderConfirmationPage() {
|
|
490
|
+
const sessionId = useSearchParams().get('session_id')
|
|
491
|
+
const { status, order, recheck } = useShopOrderStatus(sessionId)
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<OrderConfirmation
|
|
495
|
+
status={status}
|
|
496
|
+
order={order}
|
|
497
|
+
sessionId={sessionId ?? undefined}
|
|
498
|
+
cartHref='/shop/cart'
|
|
499
|
+
onRecheck={recheck}
|
|
500
|
+
/>
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Polls `GET /api/shop/order/{sessionId}` — the SITE's OWN Route Handler built
|
|
506
|
+
from `createShopOrderRoute`, never base-api directly — via `fetch` with an
|
|
507
|
+
`AbortController`, for a configurable window (`timeoutMs`, default 30000ms)
|
|
508
|
+
with backoff (`intervalMs` × `backoffFactor` per attempt, capped at
|
|
509
|
+
`maxIntervalMs`). Returns one of `loading | paid | pending_timeout | failed |
|
|
510
|
+
refunded | partially_refunded | not_found | error`, mapped from
|
|
511
|
+
`ShopOrder.paymentStatus` (`PENDING|PAID|FAILED|REFUNDED|PARTIALLY_REFUNDED`)
|
|
512
|
+
and the route's own 404. `OrderConfirmation` accepts every one of these
|
|
513
|
+
statuses directly — `loading`/`paid` are drop-in aliases for its original
|
|
514
|
+
`polling`/`found` states, so existing callers on the old 3-state API are
|
|
515
|
+
unaffected.
|
|
516
|
+
|
|
517
|
+
Clears the cart (`@reblu/site-next/cart`'s store, imperatively — not
|
|
518
|
+
`useCart()`, since this runs outside a component body) **ONLY on `paid`**.
|
|
519
|
+
`recheck()` restarts the poll loop from `loading` — wire it to
|
|
520
|
+
`OrderConfirmation`'s `onRecheck`, which renders it as the "Volver a
|
|
521
|
+
comprobar" button on `pending_timeout`/`error`.
|
|
522
|
+
|
|
523
|
+
Pass `buildUrl` to point at a non-default order route path; everything else
|
|
524
|
+
(`timeoutMs`/`intervalMs`/`backoffFactor`/`maxIntervalMs`/`requestTimeoutMs`)
|
|
525
|
+
tunes the polling window. Checkout is card-only today, so a `PENDING` order
|
|
526
|
+
past the window is expected to be transient — see REB-836 for the
|
|
527
|
+
redirect-origin hardening this flow sits behind.
|
|
528
|
+
|
|
529
|
+
`timeoutMs` (default 30000ms) bounds the WHOLE polling window via its own
|
|
530
|
+
independent timer — it fires `pending_timeout` even if a single request
|
|
531
|
+
hangs forever, never relying on that request ever settling. `requestTimeoutMs`
|
|
532
|
+
(default 8000ms) is the separate PER-REQUEST timeout: one slow/hung request
|
|
533
|
+
past it is aborted and retried with backoff (transient, not fatal); only the
|
|
534
|
+
window elapsing is terminal.
|
|
535
|
+
|
|
536
|
+
### Standard success/cancel URLs (REB-837)
|
|
537
|
+
|
|
538
|
+
`createShopCheckoutRoute` builds `successUrl`/`cancelUrl` for you when the
|
|
539
|
+
posted body doesn't already include them — an EXPLICIT value on the body
|
|
540
|
+
always wins, so a SITE that already builds its own is completely unaffected:
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
// app/api/shop/checkout/route.ts — defaults to /cart?pago=ok&session_id={CHECKOUT_SESSION_ID}
|
|
544
|
+
// and /cart?pago=cancelado
|
|
545
|
+
export const POST = createShopCheckoutRoute()
|
|
546
|
+
|
|
547
|
+
// Custom cart path — reshapes BOTH defaults:
|
|
548
|
+
export const POST = createShopCheckoutRoute({ cartPath: '/tienda/carrito' })
|
|
549
|
+
|
|
550
|
+
// Fully custom paths:
|
|
551
|
+
export const POST = createShopCheckoutRoute({
|
|
552
|
+
successPath: '/checkout/gracias?session_id={CHECKOUT_SESSION_ID}',
|
|
553
|
+
cancelPath: '/checkout/cancelado',
|
|
554
|
+
})
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Built absolute from the REQUEST's own origin (`request.nextUrl.origin`) —
|
|
558
|
+
never a caller-supplied value, so the built URL can never point off-SITE.
|
|
559
|
+
The `{CHECKOUT_SESSION_ID}` placeholder is preserved verbatim; Stripe
|
|
560
|
+
substitutes it at redirect time.
|
|
561
|
+
|
|
562
|
+
`useCheckoutReturn()` (client hook, `./shop`) parses the return query on the
|
|
563
|
+
cart page:
|
|
564
|
+
|
|
565
|
+
```tsx
|
|
566
|
+
'use client'
|
|
567
|
+
import { useCheckoutReturn } from '@reblu/site-next/shop'
|
|
568
|
+
import { CartSummary } from '@reblu/site-blocks'
|
|
569
|
+
|
|
570
|
+
function CartPage() {
|
|
571
|
+
const { status } = useCheckoutReturn() // 'ok' | 'cancelled' | null
|
|
572
|
+
// ...
|
|
573
|
+
return <CartSummary {...summaryProps} notice={status === 'cancelled'} />
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
`CartSummary`/`CartLineList`'s `notice` prop (`@reblu/site-blocks`) renders
|
|
578
|
+
the default «Pago cancelado: tu carrito sigue aquí» copy when `true`, or any
|
|
579
|
+
custom `ReactNode` — see that package's README.
|
|
580
|
+
|
|
395
581
|
## Structured data (JSON-LD)
|
|
396
582
|
|
|
397
583
|
`@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
|
-
|
|
51
|
-
(0,
|
|
52
|
-
(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 < requestedQty ? "clamped" : "added",
|
|
78
|
+
quantity: newQty
|
|
79
|
+
};
|
|
59
80
|
return {
|
|
60
|
-
items: state.items.
|
|
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
|
|
64
|
-
if (
|
|
86
|
+
const qty = normalizeQuantity(item.quantity, item.maxStock);
|
|
87
|
+
if (qty < 1) {
|
|
88
|
+
outcome = { status: "refused", quantity: 0 };
|
|
65
89
|
return {};
|
|
66
90
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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`); `'refused'` means nothing changed\n * (invalid quantity, or the product is out of stock).\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 outcome = {\n status: newQty < requestedQty ? 'clamped' : '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":";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACEA,mBAA0B;;;ACF1B,qBAAuB;AACvB,wBAAwB;AAgBjB,IAAM,mBAAmB;AAWzB,IAAM,6BAA6B;AAgD1C,SAAS,WAAW,GAAa,WAAmB,WAAoC;AACtF,SAAO,EAAE,cAAc,cAAc,EAAE,aAAa,WAAW,aAAa;AAC9E;AAqBA,SAAS,kBAAkB,UAAkB,UAAiC;AAC5E,MAAI,CAAC,OAAO,SAAS,QAAQ,EAAG,QAAO;AACvC,QAAM,YAAY,KAAK,MAAM,QAAQ;AACrC,MAAI,YAAY,EAAG,QAAO;AAC1B,MAAI,aAAa,KAAM,QAAO;AAC9B,QAAM,eAAe,KAAK,MAAM,QAAQ;AACxC,MAAI,eAAe,EAAG,QAAO;AAC7B,SAAO,KAAK,IAAI,WAAW,YAAY;AACzC;AAuBA,SAAS,kBAAkB;AACzB,aAAO,uBAAkB;AAAA,QACvB;AAAA,MACE,CAAC,KAAK,SAAS;AAAA,QACb,OAAO,CAAC;AAAA,QAER,SAAS,CAAC,SAAS;AAGjB,cAAI,UAA0B,EAAE,QAAQ,WAAW,UAAU,EAAE;AAE/D,cAAI,CAAC,UAAU;AACb,kBAAM,WAAW,MAAM,MAAM,KAAK,CAAC,MAAM,WAAW,GAAG,KAAK,WAAW,KAAK,SAAS,CAAC;AAEtF,gBAAI,UAAU;AAMZ,kBAAI,KAAK,aAAa,QAAQ,KAAK,MAAM,KAAK,QAAQ,IAAI,GAAG;AAC3D,0BAAU,EAAE,QAAQ,WAAW,UAAU,EAAE;AAC3C,uBAAO;AAAA,kBACL,OAAO,MAAM,MAAM,OAAO,CAAC,MAAM,CAAC,WAAW,GAAG,KAAK,WAAW,KAAK,SAAS,CAAC;AAAA,gBACjF;AAAA,cACF;AAEA,oBAAM,eAAe,SAAS,WAAW,KAAK;AAC9C,oBAAM,SAAS,kBAAkB,cAAc,KAAK,QAAQ;AAC5D,kBAAI,SAAS,GAAG;AAId,0BAAU,EAAE,QAAQ,WAAW,UAAU,SAAS,SAAS;AAC3D,uBAAO,CAAC;AAAA,cACV;AAEA,wBAAU;AAAA,gBACR,QAAQ,SAAS,eAAe,YAAY;AAAA,gBAC5C,UAAU;AAAA,cACZ;AAMA,qBAAO;AAAA,gBACL,OAAO,MAAM,MAAM;AAAA,kBAAI,CAAC,MACtB,WAAW,GAAG,KAAK,WAAW,KAAK,SAAS,IACxC,EAAE,GAAG,GAAG,UAAU,QAAQ,UAAU,KAAK,SAAS,IAClD;AAAA,gBACN;AAAA,cACF;AAAA,YACF;AAEA,kBAAM,MAAM,kBAAkB,KAAK,UAAU,KAAK,QAAQ;AAC1D,gBAAI,MAAM,GAAG;AAGX,wBAAU,EAAE,QAAQ,WAAW,UAAU,EAAE;AAC3C,qBAAO,CAAC;AAAA,YACV;AAEA,sBAAU,EAAE,QAAQ,MAAM,KAAK,WAAW,YAAY,SAAS,UAAU,IAAI;AAC7E,mBAAO,EAAE,OAAO,CAAC,GAAG,MAAM,OAAO,EAAE,GAAG,MAAM,UAAU,IAAI,CAAC,EAAE;AAAA,UAC/D,CAAC;AAED,iBAAO;AAAA,QACT;AAAA,QAEA,YAAY,CAAC,WAAW,cAAc;AACpC,cAAI,CAAC,WAAW;AAAA,YACd,OAAO,MAAM,MAAM,OAAO,CAAC,MAAM,CAAC,WAAW,GAAG,WAAW,SAAS,CAAC;AAAA,UACvE,EAAE;AAAA,QACJ;AAAA,QAEA,gBAAgB,CAAC,WAAW,WAAW,aAAa;AAClD,gBAAM,WAAW,IAAI,EAAE,MAAM,KAAK,CAAC,MAAM,WAAW,GAAG,WAAW,SAAS,CAAC;AAC5E,cAAI,CAAC,SAAU;AAEf,gBAAM,SAAS,kBAAkB,UAAU,SAAS,QAAQ;AAC5D,cAAI,SAAS,GAAG;AACd,gBAAI,EAAE,WAAW,WAAW,SAAS;AACrC;AAAA,UACF;AAEA,cAAI,CAAC,WAAW;AAAA,YACd,OAAO,MAAM,MAAM;AAAA,cAAI,CAAC,MACtB,WAAW,GAAG,WAAW,SAAS,IAAI,EAAE,GAAG,GAAG,UAAU,OAAO,IAAI;AAAA,YACrE;AAAA,UACF,EAAE;AAAA,QACJ;AAAA,QAEA,WAAW,MAAM,IAAI,EAAE,OAAO,CAAC,EAAE,CAAC;AAAA,QAElC,UAAU,MAAM,IAAI,EAAE,MAAM,OAAO,CAAC,KAAK,SAAS,MAAM,KAAK,YAAY,KAAK,UAAU,CAAC;AAAA,QAEzF,cAAc,MAAM,IAAI,EAAE,MAAM,OAAO,CAAC,KAAK,SAAS,MAAM,KAAK,UAAU,CAAC;AAAA,MAC9E;AAAA,MACA;AAAA,QACE,MAAM;AAAA,QACN,SAAS;AAAA,QACT,eAAe;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AACF;AAWA,IAAM,wBAAwB,uBAAO;AAAA,EACnC,gCAAgC,0BAA0B;AAC5D;AAMA,IAAM,qBAAqB;AAEpB,IAAM,eACX,mBAAmB,qBAAqB,MACvC,mBAAmB,qBAAqB,IAAI,gBAAgB;AAexD,SAAS,2BAAiC;AAC/C,SAAO,mBAAmB,qBAAqB;AACjD;;;ADnQS;AALF,SAAS,aAAa,EAAE,SAAS,GAAkC;AACxE,8BAAU,MAAM;AACd,iBAAa,QAAQ,UAAU;AAAA,EACjC,GAAG,CAAC,CAAC;AAEL,SAAO,2EAAG,UAAS;AACrB;;;AEhBA,IAAAA,gBAAqC;AA8BrC,SAAS,mBAAmB,eAAuC;AACjE,SAAO,aAAa,QAAQ,kBAAkB,aAAa;AAC7D;AAEA,SAAS,sBAA+B;AACtC,SAAO,aAAa,QAAQ,YAAY;AAC1C;AAGA,SAAS,4BAAqC;AAC5C,SAAO;AACT;AAOO,SAAS,kBAA2B;AACzC,aAAO,oCAAqB,oBAAoB,qBAAqB,yBAAyB;AAChG;AAYO,SAAS,UAAyB;AACvC,QAAM,QAAQ,aAAa,CAAC,MAAM,EAAE,KAAK;AACzC,QAAM,UAAU,aAAa,CAAC,MAAM,EAAE,OAAO;AAC7C,QAAM,aAAa,aAAa,CAAC,MAAM,EAAE,UAAU;AACnD,QAAM,iBAAiB,aAAa,CAAC,MAAM,EAAE,cAAc;AAC3D,QAAM,YAAY,aAAa,CAAC,MAAM,EAAE,SAAS;AACjD,QAAM,WAAW,gBAAgB;AAEjC,QAAM,QAAQ,MAAM,OAAO,CAAC,KAAK,SAAS,MAAM,KAAK,YAAY,KAAK,UAAU,CAAC;AACjF,QAAM,YAAY,MAAM,OAAO,CAAC,KAAK,SAAS,MAAM,KAAK,UAAU,CAAC;AAEpE,SAAO,EAAE,OAAO,OAAO,WAAW,UAAU,SAAS,YAAY,gBAAgB,UAAU;AAC7F;","names":["import_react"]}
|