@reblu/site-next 1.13.0 → 1.14.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 +23 -0
- package/README.md +80 -0
- package/dist/cart.cjs +137 -0
- package/dist/cart.cjs.map +1 -0
- package/dist/cart.d.cts +92 -0
- package/dist/cart.d.ts +92 -0
- package/dist/cart.js +108 -0
- package/dist/cart.js.map +1 -0
- package/dist/collection-BE5dhf-r.d.cts +106 -0
- package/dist/collection-BE5dhf-r.d.ts +106 -0
- package/dist/shop-route-handlers.cjs +140 -0
- package/dist/shop-route-handlers.cjs.map +1 -0
- package/dist/shop-route-handlers.d.cts +62 -0
- package/dist/shop-route-handlers.d.ts +62 -0
- package/dist/shop-route-handlers.js +109 -0
- package/dist/shop-route-handlers.js.map +1 -0
- package/dist/shop-server.cjs +17 -0
- package/dist/shop-server.cjs.map +1 -1
- package/dist/shop-server.d.cts +17 -101
- package/dist/shop-server.d.ts +17 -101
- package/dist/shop-server.js +16 -0
- package/dist/shop-server.js.map +1 -1
- package/dist/shop.d.cts +2 -2
- package/dist/shop.d.ts +2 -2
- package/package.json +25 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @reblu/site-next
|
|
2
2
|
|
|
3
|
+
## 1.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 74a94ff: Add cart + checkout glue to the SDK (REB-829). New `./cart` subpath
|
|
8
|
+
(`CartProvider`, `useCart`, `useCartStore`, `CartItem` — zustand `persist`
|
|
9
|
+
with explicit client-side hydration, same `shop-cart` localStorage key
|
|
10
|
+
demo-site's local store already used, so an existing visitor cart survives
|
|
11
|
+
the migration). New `./shop-route-handlers` subpath (`createShopCheckoutRoute`,
|
|
12
|
+
`createShopValidateCartRoute`, `createShopApplyDiscountRoute`,
|
|
13
|
+
`createShopCheckEmailRoute`, `createShopOrderRoute`) — Route Handler
|
|
14
|
+
factories for the `/api/site/shop/{checkout,validate-cart,apply-discount,
|
|
15
|
+
check-email,order/[sessionId]}` write flow, mirroring `createProxyRoute`'s
|
|
16
|
+
existing API-key/server-only handling. `./shop-server` gains
|
|
17
|
+
`getShopOrderSummary`, a server helper for an order-confirmation Server
|
|
18
|
+
Component.
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- Updated dependencies [74a94ff]
|
|
23
|
+
- @reblu/site-contracts@1.5.0
|
|
24
|
+
- @reblu/site-client@5.2.0
|
|
25
|
+
|
|
3
26
|
## 1.13.0
|
|
4
27
|
|
|
5
28
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -312,6 +312,86 @@ resets pagination (`page`) — a filter change is never silently stuck on page
|
|
|
312
312
|
reactively to `usePathname()`/`useSearchParams()`, for a filter bar that is
|
|
313
313
|
its own Client Component.
|
|
314
314
|
|
|
315
|
+
## Cart & checkout
|
|
316
|
+
|
|
317
|
+
`./cart`, `./shop-route-handlers` and `./shop-server`'s `getShopOrderSummary`
|
|
318
|
+
(REB-829) are the cart/checkout glue behind `@reblu/site-blocks`'s
|
|
319
|
+
`CartLineList`/`CartSummary`/`CheckoutForm`/`CheckoutLoginPrompt`/
|
|
320
|
+
`OrderConfirmation` — see that package's README for the component props.
|
|
321
|
+
|
|
322
|
+
| Export | Subpath | Needs `'use client'`? |
|
|
323
|
+
| ------ | ------- | ---------------------- |
|
|
324
|
+
| `CartProvider`, `useCart()`, `useCartStore`, `CartItem` | `./cart` | **Yes** |
|
|
325
|
+
| `createShopCheckoutRoute()` | `./shop-route-handlers` | No |
|
|
326
|
+
| `createShopValidateCartRoute()` | `./shop-route-handlers` | No |
|
|
327
|
+
| `createShopApplyDiscountRoute()` | `./shop-route-handlers` | No |
|
|
328
|
+
| `createShopCheckEmailRoute()` | `./shop-route-handlers` | No |
|
|
329
|
+
| `createShopOrderRoute()` | `./shop-route-handlers` | No |
|
|
330
|
+
| `getShopOrderSummary(client, sessionId)` | `./shop-server` | No |
|
|
331
|
+
|
|
332
|
+
### Wiring a SITE
|
|
333
|
+
|
|
334
|
+
1. **Provider** — mount `CartProvider` once near the root layout, inside a
|
|
335
|
+
Client Component boundary (`app/layout.tsx` already has one for consent/
|
|
336
|
+
theme providers — add it there):
|
|
337
|
+
|
|
338
|
+
```tsx
|
|
339
|
+
import { CartProvider } from '@reblu/site-next/cart'
|
|
340
|
+
|
|
341
|
+
// inside the root layout's client provider tree
|
|
342
|
+
<CartProvider>{children}</CartProvider>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
2. **Route Handlers** — mount the five factories, one file each (mirrors the
|
|
346
|
+
`./route-handlers` auth pattern — a one-line file per route):
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
// app/api/shop/checkout/route.ts
|
|
350
|
+
import { createShopCheckoutRoute } from '@reblu/site-next/shop-route-handlers'
|
|
351
|
+
export const POST = createShopCheckoutRoute()
|
|
352
|
+
|
|
353
|
+
// app/api/shop/validate-cart/route.ts
|
|
354
|
+
export const POST = createShopValidateCartRoute()
|
|
355
|
+
|
|
356
|
+
// app/api/shop/apply-discount/route.ts
|
|
357
|
+
export const POST = createShopApplyDiscountRoute()
|
|
358
|
+
|
|
359
|
+
// app/api/shop/check-email/route.ts
|
|
360
|
+
export const POST = createShopCheckEmailRoute()
|
|
361
|
+
|
|
362
|
+
// app/api/shop/order/[sessionId]/route.ts
|
|
363
|
+
export const GET = createShopOrderRoute()
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
All five read `REBLU_API_BASE_URL`/`REBLU_API_KEY` lazily from `process.env`
|
|
367
|
+
(same as `./route-handlers`'s `createProxyRoute`, which they wrap) — the
|
|
368
|
+
API key never reaches the browser.
|
|
369
|
+
|
|
370
|
+
3. **Pages** — create `app/shop/cart/page.tsx`, `app/shop/checkout/page.tsx`
|
|
371
|
+
and `app/shop/order-confirmation/page.tsx`. Read the cart with `useCart()`
|
|
372
|
+
from `./cart`, render `@reblu/site-blocks`'s `CartLineList`/`CartSummary`/
|
|
373
|
+
`CheckoutForm`/`OrderConfirmation`, and `fetch` the Route Handlers from
|
|
374
|
+
step 2 for checkout/validate-cart/apply-discount/check-email/order (never
|
|
375
|
+
call base-api directly from a Client Component — that's what step 2's
|
|
376
|
+
Route Handlers are for). See `apps/demo-site`'s `shop/{cart,checkout,
|
|
377
|
+
order-confirmation}/page.tsx` in this monorepo for a complete, working
|
|
378
|
+
example.
|
|
379
|
+
|
|
380
|
+
### Cart persistence & consent
|
|
381
|
+
|
|
382
|
+
The cart persists to `localStorage` under the key `'shop-cart'` (exported as
|
|
383
|
+
`CART_STORAGE_KEY`), the SAME key demo-site's original hand-rolled store
|
|
384
|
+
used — a SITE migrating from a local copy keeps its visitors' existing
|
|
385
|
+
carts. This is **strictly necessary** storage (the visitor's own cart
|
|
386
|
+
contents, not tracking), so it does not need cookie-consent gating the way
|
|
387
|
+
`@reblu/site-next/consent`'s analytics scripts do.
|
|
388
|
+
|
|
389
|
+
`skipHydration: true` on the underlying zustand `persist` middleware is
|
|
390
|
+
load-bearing: it stops the store from reading `localStorage` during SSR
|
|
391
|
+
(which would throw/desync). `CartProvider` calls
|
|
392
|
+
`useCartStore.persist.rehydrate()` in a `useEffect` — mount it once, or the
|
|
393
|
+
cart UI renders permanently empty.
|
|
394
|
+
|
|
315
395
|
## Structured data (JSON-LD)
|
|
316
396
|
|
|
317
397
|
`@reblu/site-next/json-ld` (REB-622, server-only, dependency-free) renders one
|
package/dist/cart.cjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
"use client";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/cart/index.ts
|
|
22
|
+
var cart_exports = {};
|
|
23
|
+
__export(cart_exports, {
|
|
24
|
+
CART_STORAGE_KEY: () => CART_STORAGE_KEY,
|
|
25
|
+
CartProvider: () => CartProvider,
|
|
26
|
+
useCart: () => useCart,
|
|
27
|
+
useCartStore: () => useCartStore
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(cart_exports);
|
|
30
|
+
|
|
31
|
+
// src/cart/provider.tsx
|
|
32
|
+
var import_react = require("react");
|
|
33
|
+
|
|
34
|
+
// src/cart/store.ts
|
|
35
|
+
var import_zustand = require("zustand");
|
|
36
|
+
var import_middleware = require("zustand/middleware");
|
|
37
|
+
var CART_STORAGE_KEY = "shop-cart";
|
|
38
|
+
function isSameItem(a, productId, variantId) {
|
|
39
|
+
return a.productId === productId && (a.variantId ?? null) === (variantId ?? null);
|
|
40
|
+
}
|
|
41
|
+
function normalizeQuantity(quantity, maxStock) {
|
|
42
|
+
if (!Number.isFinite(quantity)) return 0;
|
|
43
|
+
const truncated = Math.trunc(quantity);
|
|
44
|
+
if (truncated < 1) return 0;
|
|
45
|
+
if (maxStock === null) return truncated;
|
|
46
|
+
const maxTruncated = Math.trunc(maxStock);
|
|
47
|
+
if (maxTruncated < 1) return 0;
|
|
48
|
+
return Math.min(truncated, maxTruncated);
|
|
49
|
+
}
|
|
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) {
|
|
59
|
+
return {
|
|
60
|
+
items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId))
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const newQty = normalizeQuantity(existing.quantity + item.quantity, item.maxStock);
|
|
64
|
+
if (newQty < 1) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
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 {};
|
|
76
|
+
}
|
|
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
|
+
)
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// src/cart/provider.tsx
|
|
111
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
112
|
+
function CartProvider({ children }) {
|
|
113
|
+
(0, import_react.useEffect)(() => {
|
|
114
|
+
useCartStore.persist.rehydrate();
|
|
115
|
+
}, []);
|
|
116
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, { children });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/cart/use-cart.ts
|
|
120
|
+
function useCart() {
|
|
121
|
+
const items = useCartStore((s) => s.items);
|
|
122
|
+
const addItem = useCartStore((s) => s.addItem);
|
|
123
|
+
const removeItem = useCartStore((s) => s.removeItem);
|
|
124
|
+
const updateQuantity = useCartStore((s) => s.updateQuantity);
|
|
125
|
+
const clearCart = useCartStore((s) => s.clearCart);
|
|
126
|
+
const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
|
127
|
+
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
|
128
|
+
return { items, total, itemCount, addItem, removeItem, updateQuantity, clearCart };
|
|
129
|
+
}
|
|
130
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
131
|
+
0 && (module.exports = {
|
|
132
|
+
CART_STORAGE_KEY,
|
|
133
|
+
CartProvider,
|
|
134
|
+
useCart,
|
|
135
|
+
useCartStore
|
|
136
|
+
});
|
|
137
|
+
//# sourceMappingURL=cart.cjs.map
|
|
@@ -0,0 +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":[]}
|
package/dist/cart.d.cts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import * as zustand_middleware from 'zustand/middleware';
|
|
3
|
+
import * as zustand from 'zustand';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `CartProvider` — mount this once near the root layout (inside a Client
|
|
7
|
+
* Component boundary). It owns the ONE explicit-hydration call
|
|
8
|
+
* `useCartStore.persist.rehydrate()` requires (`store.ts`'s `skipHydration`
|
|
9
|
+
* doc) — a SITE that forgets to mount it gets an always-empty cart that never
|
|
10
|
+
* reads back whatever a previous visit (or page reload) persisted.
|
|
11
|
+
*/
|
|
12
|
+
declare function CartProvider({ children }: {
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
}): react_jsx_runtime.JSX.Element;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `useCartStore` — the SDK's cart state (REB-829). Ported verbatim (same
|
|
18
|
+
* shape, same quantity-clamping rules) from demo-site's local
|
|
19
|
+
* `lib/store/cart-store.ts`, which every SITE was otherwise expected to
|
|
20
|
+
* hand-copy. `CART_STORAGE_KEY` stays exactly `'shop-cart'` on purpose: a
|
|
21
|
+
* SITE migrating from the local copy keeps its visitors' existing carts.
|
|
22
|
+
*
|
|
23
|
+
* `skipHydration: true` (zustand persist) is load-bearing for SSR: without
|
|
24
|
+
* it, zustand's persist middleware reads `localStorage` synchronously during
|
|
25
|
+
* module evaluation, which throws (or silently desyncs) on the server. The
|
|
26
|
+
* `CartProvider` (`./provider`) calls `useCartStore.persist.rehydrate()`
|
|
27
|
+
* inside a `useEffect`, so hydration only ever happens client-side, after
|
|
28
|
+
* mount — the same explicit-hydration contract the SITE README documents.
|
|
29
|
+
*/
|
|
30
|
+
declare const CART_STORAGE_KEY = "shop-cart";
|
|
31
|
+
interface CartItem {
|
|
32
|
+
productId: string;
|
|
33
|
+
variantId?: string | null;
|
|
34
|
+
name: string;
|
|
35
|
+
variantName?: string;
|
|
36
|
+
/** Integer cents — every Reblu shop price travels as cents, never a float. */
|
|
37
|
+
unitPrice: number;
|
|
38
|
+
quantity: number;
|
|
39
|
+
imageUrl?: string;
|
|
40
|
+
/** `null` means untracked/unlimited stock — never clamp against it. */
|
|
41
|
+
maxStock: number | null;
|
|
42
|
+
/** Basis points, e.g. `2100` = 21%. `null`/`undefined` = unknown or exempt. */
|
|
43
|
+
taxRateBps?: number | null;
|
|
44
|
+
taxRateName?: string | null;
|
|
45
|
+
}
|
|
46
|
+
interface CartStore {
|
|
47
|
+
items: CartItem[];
|
|
48
|
+
addItem: (item: CartItem) => void;
|
|
49
|
+
removeItem: (productId: string, variantId?: string | null) => void;
|
|
50
|
+
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
51
|
+
clearCart: () => void;
|
|
52
|
+
getTotal: () => number;
|
|
53
|
+
getItemCount: () => number;
|
|
54
|
+
}
|
|
55
|
+
declare const useCartStore: zustand.UseBoundStore<Omit<zustand.StoreApi<CartStore>, "setState" | "persist"> & {
|
|
56
|
+
setState(partial: CartStore | Partial<CartStore> | ((state: CartStore) => CartStore | Partial<CartStore>), replace?: false | undefined): unknown;
|
|
57
|
+
setState(state: CartStore | ((state: CartStore) => CartStore), replace: true): unknown;
|
|
58
|
+
persist: {
|
|
59
|
+
setOptions: (options: Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>) => void;
|
|
60
|
+
clearStorage: () => void;
|
|
61
|
+
rehydrate: () => Promise<void> | void;
|
|
62
|
+
hasHydrated: () => boolean;
|
|
63
|
+
onHydrate: (fn: (state: CartStore) => void) => () => void;
|
|
64
|
+
onFinishHydration: (fn: (state: CartStore) => void) => () => void;
|
|
65
|
+
getOptions: () => Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>;
|
|
66
|
+
};
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
interface UseCartResult {
|
|
70
|
+
items: CartItem[];
|
|
71
|
+
/** Sum of every line's `unitPrice * quantity`, in integer cents. */
|
|
72
|
+
total: number;
|
|
73
|
+
/** Sum of every line's `quantity`. */
|
|
74
|
+
itemCount: number;
|
|
75
|
+
addItem: (item: CartItem) => void;
|
|
76
|
+
removeItem: (productId: string, variantId?: string | null) => void;
|
|
77
|
+
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
78
|
+
clearCart: () => void;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* `useCart` — the ergonomic entry point for a SITE's cart UI: a single
|
|
82
|
+
* selector call that re-renders only on `items` changes, with `total`/
|
|
83
|
+
* `itemCount` derived inline (cheap — a plain `reduce` over a typically-small
|
|
84
|
+
* array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`
|
|
85
|
+
* selectors, which are stable function references and would NOT trigger a
|
|
86
|
+
* re-render on their own. A SITE that wants raw store access (existing
|
|
87
|
+
* per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still
|
|
88
|
+
* import `useCartStore` directly — this hook doesn't replace it.
|
|
89
|
+
*/
|
|
90
|
+
declare function useCart(): UseCartResult;
|
|
91
|
+
|
|
92
|
+
export { CART_STORAGE_KEY, type CartItem, CartProvider, type CartStore, type UseCartResult, useCart, useCartStore };
|
package/dist/cart.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import * as zustand_middleware from 'zustand/middleware';
|
|
3
|
+
import * as zustand from 'zustand';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `CartProvider` — mount this once near the root layout (inside a Client
|
|
7
|
+
* Component boundary). It owns the ONE explicit-hydration call
|
|
8
|
+
* `useCartStore.persist.rehydrate()` requires (`store.ts`'s `skipHydration`
|
|
9
|
+
* doc) — a SITE that forgets to mount it gets an always-empty cart that never
|
|
10
|
+
* reads back whatever a previous visit (or page reload) persisted.
|
|
11
|
+
*/
|
|
12
|
+
declare function CartProvider({ children }: {
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
}): react_jsx_runtime.JSX.Element;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `useCartStore` — the SDK's cart state (REB-829). Ported verbatim (same
|
|
18
|
+
* shape, same quantity-clamping rules) from demo-site's local
|
|
19
|
+
* `lib/store/cart-store.ts`, which every SITE was otherwise expected to
|
|
20
|
+
* hand-copy. `CART_STORAGE_KEY` stays exactly `'shop-cart'` on purpose: a
|
|
21
|
+
* SITE migrating from the local copy keeps its visitors' existing carts.
|
|
22
|
+
*
|
|
23
|
+
* `skipHydration: true` (zustand persist) is load-bearing for SSR: without
|
|
24
|
+
* it, zustand's persist middleware reads `localStorage` synchronously during
|
|
25
|
+
* module evaluation, which throws (or silently desyncs) on the server. The
|
|
26
|
+
* `CartProvider` (`./provider`) calls `useCartStore.persist.rehydrate()`
|
|
27
|
+
* inside a `useEffect`, so hydration only ever happens client-side, after
|
|
28
|
+
* mount — the same explicit-hydration contract the SITE README documents.
|
|
29
|
+
*/
|
|
30
|
+
declare const CART_STORAGE_KEY = "shop-cart";
|
|
31
|
+
interface CartItem {
|
|
32
|
+
productId: string;
|
|
33
|
+
variantId?: string | null;
|
|
34
|
+
name: string;
|
|
35
|
+
variantName?: string;
|
|
36
|
+
/** Integer cents — every Reblu shop price travels as cents, never a float. */
|
|
37
|
+
unitPrice: number;
|
|
38
|
+
quantity: number;
|
|
39
|
+
imageUrl?: string;
|
|
40
|
+
/** `null` means untracked/unlimited stock — never clamp against it. */
|
|
41
|
+
maxStock: number | null;
|
|
42
|
+
/** Basis points, e.g. `2100` = 21%. `null`/`undefined` = unknown or exempt. */
|
|
43
|
+
taxRateBps?: number | null;
|
|
44
|
+
taxRateName?: string | null;
|
|
45
|
+
}
|
|
46
|
+
interface CartStore {
|
|
47
|
+
items: CartItem[];
|
|
48
|
+
addItem: (item: CartItem) => void;
|
|
49
|
+
removeItem: (productId: string, variantId?: string | null) => void;
|
|
50
|
+
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
51
|
+
clearCart: () => void;
|
|
52
|
+
getTotal: () => number;
|
|
53
|
+
getItemCount: () => number;
|
|
54
|
+
}
|
|
55
|
+
declare const useCartStore: zustand.UseBoundStore<Omit<zustand.StoreApi<CartStore>, "setState" | "persist"> & {
|
|
56
|
+
setState(partial: CartStore | Partial<CartStore> | ((state: CartStore) => CartStore | Partial<CartStore>), replace?: false | undefined): unknown;
|
|
57
|
+
setState(state: CartStore | ((state: CartStore) => CartStore), replace: true): unknown;
|
|
58
|
+
persist: {
|
|
59
|
+
setOptions: (options: Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>) => void;
|
|
60
|
+
clearStorage: () => void;
|
|
61
|
+
rehydrate: () => Promise<void> | void;
|
|
62
|
+
hasHydrated: () => boolean;
|
|
63
|
+
onHydrate: (fn: (state: CartStore) => void) => () => void;
|
|
64
|
+
onFinishHydration: (fn: (state: CartStore) => void) => () => void;
|
|
65
|
+
getOptions: () => Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>;
|
|
66
|
+
};
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
interface UseCartResult {
|
|
70
|
+
items: CartItem[];
|
|
71
|
+
/** Sum of every line's `unitPrice * quantity`, in integer cents. */
|
|
72
|
+
total: number;
|
|
73
|
+
/** Sum of every line's `quantity`. */
|
|
74
|
+
itemCount: number;
|
|
75
|
+
addItem: (item: CartItem) => void;
|
|
76
|
+
removeItem: (productId: string, variantId?: string | null) => void;
|
|
77
|
+
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
78
|
+
clearCart: () => void;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* `useCart` — the ergonomic entry point for a SITE's cart UI: a single
|
|
82
|
+
* selector call that re-renders only on `items` changes, with `total`/
|
|
83
|
+
* `itemCount` derived inline (cheap — a plain `reduce` over a typically-small
|
|
84
|
+
* array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`
|
|
85
|
+
* selectors, which are stable function references and would NOT trigger a
|
|
86
|
+
* re-render on their own. A SITE that wants raw store access (existing
|
|
87
|
+
* per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still
|
|
88
|
+
* import `useCartStore` directly — this hook doesn't replace it.
|
|
89
|
+
*/
|
|
90
|
+
declare function useCart(): UseCartResult;
|
|
91
|
+
|
|
92
|
+
export { CART_STORAGE_KEY, type CartItem, CartProvider, type CartStore, type UseCartResult, useCart, useCartStore };
|
package/dist/cart.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/cart/provider.tsx
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
// src/cart/store.ts
|
|
7
|
+
import { create } from "zustand";
|
|
8
|
+
import { persist } from "zustand/middleware";
|
|
9
|
+
var CART_STORAGE_KEY = "shop-cart";
|
|
10
|
+
function isSameItem(a, productId, variantId) {
|
|
11
|
+
return a.productId === productId && (a.variantId ?? null) === (variantId ?? null);
|
|
12
|
+
}
|
|
13
|
+
function normalizeQuantity(quantity, maxStock) {
|
|
14
|
+
if (!Number.isFinite(quantity)) return 0;
|
|
15
|
+
const truncated = Math.trunc(quantity);
|
|
16
|
+
if (truncated < 1) return 0;
|
|
17
|
+
if (maxStock === null) return truncated;
|
|
18
|
+
const maxTruncated = Math.trunc(maxStock);
|
|
19
|
+
if (maxTruncated < 1) return 0;
|
|
20
|
+
return Math.min(truncated, maxTruncated);
|
|
21
|
+
}
|
|
22
|
+
var useCartStore = create()(
|
|
23
|
+
persist(
|
|
24
|
+
(set, get) => ({
|
|
25
|
+
items: [],
|
|
26
|
+
addItem: (item) => {
|
|
27
|
+
set((state) => {
|
|
28
|
+
const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId));
|
|
29
|
+
if (existing) {
|
|
30
|
+
if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {
|
|
31
|
+
return {
|
|
32
|
+
items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId))
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const newQty = normalizeQuantity(existing.quantity + item.quantity, item.maxStock);
|
|
36
|
+
if (newQty < 1) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
items: state.items.map(
|
|
41
|
+
(i) => isSameItem(i, item.productId, item.variantId) ? { ...i, quantity: newQty, maxStock: item.maxStock } : i
|
|
42
|
+
)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const qty = normalizeQuantity(item.quantity, item.maxStock);
|
|
46
|
+
if (qty < 1) {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
return { items: [...state.items, { ...item, quantity: qty }] };
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
removeItem: (productId, variantId) => {
|
|
53
|
+
set((state) => ({
|
|
54
|
+
items: state.items.filter((i) => !isSameItem(i, productId, variantId))
|
|
55
|
+
}));
|
|
56
|
+
},
|
|
57
|
+
updateQuantity: (productId, variantId, quantity) => {
|
|
58
|
+
const existing = get().items.find((i) => isSameItem(i, productId, variantId));
|
|
59
|
+
if (!existing) return;
|
|
60
|
+
const newQty = normalizeQuantity(quantity, existing.maxStock);
|
|
61
|
+
if (newQty < 1) {
|
|
62
|
+
get().removeItem(productId, variantId);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
set((state) => ({
|
|
66
|
+
items: state.items.map(
|
|
67
|
+
(i) => isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i
|
|
68
|
+
)
|
|
69
|
+
}));
|
|
70
|
+
},
|
|
71
|
+
clearCart: () => set({ items: [] }),
|
|
72
|
+
getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),
|
|
73
|
+
getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0)
|
|
74
|
+
}),
|
|
75
|
+
{
|
|
76
|
+
name: CART_STORAGE_KEY,
|
|
77
|
+
skipHydration: true
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// src/cart/provider.tsx
|
|
83
|
+
import { Fragment, jsx } from "react/jsx-runtime";
|
|
84
|
+
function CartProvider({ children }) {
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
useCartStore.persist.rehydrate();
|
|
87
|
+
}, []);
|
|
88
|
+
return /* @__PURE__ */ jsx(Fragment, { children });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/cart/use-cart.ts
|
|
92
|
+
function useCart() {
|
|
93
|
+
const items = useCartStore((s) => s.items);
|
|
94
|
+
const addItem = useCartStore((s) => s.addItem);
|
|
95
|
+
const removeItem = useCartStore((s) => s.removeItem);
|
|
96
|
+
const updateQuantity = useCartStore((s) => s.updateQuantity);
|
|
97
|
+
const clearCart = useCartStore((s) => s.clearCart);
|
|
98
|
+
const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
|
99
|
+
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
|
100
|
+
return { items, total, itemCount, addItem, removeItem, updateQuantity, clearCart };
|
|
101
|
+
}
|
|
102
|
+
export {
|
|
103
|
+
CART_STORAGE_KEY,
|
|
104
|
+
CartProvider,
|
|
105
|
+
useCart,
|
|
106
|
+
useCartStore
|
|
107
|
+
};
|
|
108
|
+
//# sourceMappingURL=cart.js.map
|
package/dist/cart.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cart/provider.tsx","../src/cart/store.ts","../src/cart/use-cart.ts"],"sourcesContent":["'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":[]}
|