@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 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":[]}
@@ -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
@@ -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":[]}