@reblu/site-next 1.13.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 +96 -0
- package/README.md +266 -0
- package/dist/cart.cjs +180 -0
- package/dist/cart.cjs.map +1 -0
- package/dist/cart.d.cts +169 -0
- package/dist/cart.d.ts +169 -0
- package/dist/cart.js +148 -0
- package/dist/cart.js.map +1 -0
- package/dist/collection-gAD_g03I.d.cts +106 -0
- package/dist/collection-gAD_g03I.d.ts +106 -0
- 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 +235 -0
- package/dist/shop-route-handlers.cjs.map +1 -0
- package/dist/shop-route-handlers.d.cts +126 -0
- package/dist/shop-route-handlers.d.ts +126 -0
- package/dist/shop-route-handlers.js +204 -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.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 +25 -4
package/dist/cart.d.cts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
/**
|
|
32
|
+
* The zustand `persist` schema version for the cart's PERSISTED shape (the
|
|
33
|
+
* `CartItem[]` written to `localStorage` under `CART_STORAGE_KEY`) — bump
|
|
34
|
+
* this (with a `migrate` option) if that shape ever changes incompatibly.
|
|
35
|
+
* Also folded into {@link CART_STORE_GLOBAL_KEY} (REB-837 review fix): a
|
|
36
|
+
* future incompatible shape change should mint a NEW global singleton
|
|
37
|
+
* rather than let a stale, already-evaluated old-shape instance (e.g. kept
|
|
38
|
+
* alive across an HMR reload in dev) silently satisfy the new one.
|
|
39
|
+
*/
|
|
40
|
+
declare const CART_STORE_PERSIST_VERSION = 0;
|
|
41
|
+
interface CartItem {
|
|
42
|
+
productId: string;
|
|
43
|
+
variantId?: string | null;
|
|
44
|
+
name: string;
|
|
45
|
+
variantName?: string;
|
|
46
|
+
/** Integer cents — every Reblu shop price travels as cents, never a float. */
|
|
47
|
+
unitPrice: number;
|
|
48
|
+
quantity: number;
|
|
49
|
+
imageUrl?: string;
|
|
50
|
+
/** `null` means untracked/unlimited stock — never clamp against it. */
|
|
51
|
+
maxStock: number | null;
|
|
52
|
+
/** Basis points, e.g. `2100` = 21%. `null`/`undefined` = unknown or exempt. */
|
|
53
|
+
taxRateBps?: number | null;
|
|
54
|
+
taxRateName?: string | null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Outcome of `addItem` (REB-837): a SITE's add-to-cart feedback (toast,
|
|
58
|
+
* `AddToCartButton`'s built-in confirmed/error state) needs to know what
|
|
59
|
+
* actually happened, not just fire-and-forget — `'clamped'` only differs
|
|
60
|
+
* from `'added'` in the RESULTING quantity reaching less than what was
|
|
61
|
+
* requested (capped by `maxStock`); `'refused'` means nothing changed
|
|
62
|
+
* (invalid quantity, or the product is out of stock).
|
|
63
|
+
*/
|
|
64
|
+
type AddItemStatus = 'added' | 'clamped' | 'refused';
|
|
65
|
+
interface AddItemOutcome {
|
|
66
|
+
status: AddItemStatus;
|
|
67
|
+
/** The resulting line quantity after the call (`0` when refused and no line exists). */
|
|
68
|
+
quantity: number;
|
|
69
|
+
}
|
|
70
|
+
interface CartStore {
|
|
71
|
+
items: CartItem[];
|
|
72
|
+
addItem: (item: CartItem) => AddItemOutcome;
|
|
73
|
+
removeItem: (productId: string, variantId?: string | null) => void;
|
|
74
|
+
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
75
|
+
clearCart: () => void;
|
|
76
|
+
getTotal: () => number;
|
|
77
|
+
getItemCount: () => number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* REB-837 cross-entry singleton guard — the mutation that caught it: a
|
|
81
|
+
* single test rendering `useShopOrderStatus` (the `./shop` tsup entry) and
|
|
82
|
+
* asserting on `useCartStore` (the `./cart` entry) saw the order resolve to
|
|
83
|
+
* `paid` but the cart stay non-empty. Root cause: `splitting: false` (R4,
|
|
84
|
+
* `tsup.config.ts`) bundles each entry standalone, so a plain relative
|
|
85
|
+
* import of this file from `./shop` INLINES a SEPARATE copy of this
|
|
86
|
+
* `create(...)` call — a second, disconnected store instance with its own
|
|
87
|
+
* state, not the one `@reblu/site-next/cart` consumers read. `clearCart()`
|
|
88
|
+
* from inside the hook silently cleared the WRONG store.
|
|
89
|
+
*
|
|
90
|
+
* `globalThis` keyed by a `Symbol.for(...)` (same registry entry regardless
|
|
91
|
+
* of which bundle's copy of this module evaluates it first) makes every
|
|
92
|
+
* entry that imports this file — however many times esbuild inlines it —
|
|
93
|
+
* resolve to the SAME store. Verified by mutation: removing this guard
|
|
94
|
+
* (reverting to a bare `export const useCartStore = create(...)`) turns
|
|
95
|
+
* `use-shop-order-status.test.ts`'s cart-clearing assertions red again once
|
|
96
|
+
* the hook is exercised through its OWN entry rather than imported directly
|
|
97
|
+
* within `cart`'s own test file (which never saw the bug, since it only
|
|
98
|
+
* ever touches ONE copy of this module).
|
|
99
|
+
*/
|
|
100
|
+
declare function createCartStore(): zustand.UseBoundStore<Omit<zustand.StoreApi<CartStore>, "setState" | "persist"> & {
|
|
101
|
+
setState(partial: CartStore | Partial<CartStore> | ((state: CartStore) => CartStore | Partial<CartStore>), replace?: false | undefined): unknown;
|
|
102
|
+
setState(state: CartStore | ((state: CartStore) => CartStore), replace: true): unknown;
|
|
103
|
+
persist: {
|
|
104
|
+
setOptions: (options: Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>) => void;
|
|
105
|
+
clearStorage: () => void;
|
|
106
|
+
rehydrate: () => Promise<void> | void;
|
|
107
|
+
hasHydrated: () => boolean;
|
|
108
|
+
onHydrate: (fn: (state: CartStore) => void) => () => void;
|
|
109
|
+
onFinishHydration: (fn: (state: CartStore) => void) => () => void;
|
|
110
|
+
getOptions: () => Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>;
|
|
111
|
+
};
|
|
112
|
+
}>;
|
|
113
|
+
type CartStoreApi = ReturnType<typeof createCartStore>;
|
|
114
|
+
declare const useCartStore: CartStoreApi;
|
|
115
|
+
/**
|
|
116
|
+
* TEST-ONLY (REB-837 review fix). Clears the `globalThis`-keyed slot so the
|
|
117
|
+
* NEXT access to `useCartStore` — in THIS module instance — creates a fresh
|
|
118
|
+
* store. The singleton is intentionally shared process-wide; most tests
|
|
119
|
+
* should keep resetting STATE via `useCartStore.setState(...)` in
|
|
120
|
+
* `beforeEach`, not the instance itself. Reach for this only to prove
|
|
121
|
+
* something about the singleton mechanism (e.g. the cross-entry test in
|
|
122
|
+
* `tests/cart-store-cross-entry-singleton.test.ts`) — it does not and
|
|
123
|
+
* cannot affect an ALREADY-evaluated copy of this module inlined into a
|
|
124
|
+
* different tsup entry's bundle (each such copy owns its own `const
|
|
125
|
+
* globalForCartStore`/`CART_STORE_GLOBAL_KEY` closed over at that copy's
|
|
126
|
+
* own module-evaluation time, same as any other ES module binding).
|
|
127
|
+
*/
|
|
128
|
+
declare function __resetCartStoreForTests(): void;
|
|
129
|
+
|
|
130
|
+
interface UseCartResult {
|
|
131
|
+
items: CartItem[];
|
|
132
|
+
/** Sum of every line's `unitPrice * quantity`, in integer cents. */
|
|
133
|
+
total: number;
|
|
134
|
+
/** Sum of every line's `quantity`. */
|
|
135
|
+
itemCount: number;
|
|
136
|
+
/**
|
|
137
|
+
* Whether `CartProvider`'s `persist.rehydrate()` has finished (REB-837).
|
|
138
|
+
* `false` on the server and on the very first client render — `items`
|
|
139
|
+
* (and therefore `itemCount`) jumps from `0` to whatever was persisted the
|
|
140
|
+
* instant hydration finishes, which is NOT a real "item added" event. A
|
|
141
|
+
* consumer driving `CartIcon`'s `bumpOnAdd` MUST gate the bump on this
|
|
142
|
+
* flag (see `CartIcon`'s own guard) or every page load with a non-empty
|
|
143
|
+
* cart bumps the badge once for free.
|
|
144
|
+
*/
|
|
145
|
+
hydrated: boolean;
|
|
146
|
+
addItem: (item: CartItem) => AddItemOutcome;
|
|
147
|
+
removeItem: (productId: string, variantId?: string | null) => void;
|
|
148
|
+
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
149
|
+
clearCart: () => void;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* `useCartHydrated` — standalone so a SITE that only needs the flag (e.g. to
|
|
153
|
+
* gate its own effects) doesn't have to subscribe to the whole cart via
|
|
154
|
+
* `useCart()`.
|
|
155
|
+
*/
|
|
156
|
+
declare function useCartHydrated(): boolean;
|
|
157
|
+
/**
|
|
158
|
+
* `useCart` — the ergonomic entry point for a SITE's cart UI: a single
|
|
159
|
+
* selector call that re-renders only on `items` changes, with `total`/
|
|
160
|
+
* `itemCount` derived inline (cheap — a plain `reduce` over a typically-small
|
|
161
|
+
* array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`
|
|
162
|
+
* selectors, which are stable function references and would NOT trigger a
|
|
163
|
+
* re-render on their own. A SITE that wants raw store access (existing
|
|
164
|
+
* per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still
|
|
165
|
+
* import `useCartStore` directly — this hook doesn't replace it.
|
|
166
|
+
*/
|
|
167
|
+
declare function useCart(): UseCartResult;
|
|
168
|
+
|
|
169
|
+
export { type AddItemOutcome, type AddItemStatus, CART_STORAGE_KEY, CART_STORE_PERSIST_VERSION, type CartItem, CartProvider, type CartStore, type UseCartResult, __resetCartStoreForTests, useCart, useCartHydrated, useCartStore };
|
package/dist/cart.d.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
/**
|
|
32
|
+
* The zustand `persist` schema version for the cart's PERSISTED shape (the
|
|
33
|
+
* `CartItem[]` written to `localStorage` under `CART_STORAGE_KEY`) — bump
|
|
34
|
+
* this (with a `migrate` option) if that shape ever changes incompatibly.
|
|
35
|
+
* Also folded into {@link CART_STORE_GLOBAL_KEY} (REB-837 review fix): a
|
|
36
|
+
* future incompatible shape change should mint a NEW global singleton
|
|
37
|
+
* rather than let a stale, already-evaluated old-shape instance (e.g. kept
|
|
38
|
+
* alive across an HMR reload in dev) silently satisfy the new one.
|
|
39
|
+
*/
|
|
40
|
+
declare const CART_STORE_PERSIST_VERSION = 0;
|
|
41
|
+
interface CartItem {
|
|
42
|
+
productId: string;
|
|
43
|
+
variantId?: string | null;
|
|
44
|
+
name: string;
|
|
45
|
+
variantName?: string;
|
|
46
|
+
/** Integer cents — every Reblu shop price travels as cents, never a float. */
|
|
47
|
+
unitPrice: number;
|
|
48
|
+
quantity: number;
|
|
49
|
+
imageUrl?: string;
|
|
50
|
+
/** `null` means untracked/unlimited stock — never clamp against it. */
|
|
51
|
+
maxStock: number | null;
|
|
52
|
+
/** Basis points, e.g. `2100` = 21%. `null`/`undefined` = unknown or exempt. */
|
|
53
|
+
taxRateBps?: number | null;
|
|
54
|
+
taxRateName?: string | null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Outcome of `addItem` (REB-837): a SITE's add-to-cart feedback (toast,
|
|
58
|
+
* `AddToCartButton`'s built-in confirmed/error state) needs to know what
|
|
59
|
+
* actually happened, not just fire-and-forget — `'clamped'` only differs
|
|
60
|
+
* from `'added'` in the RESULTING quantity reaching less than what was
|
|
61
|
+
* requested (capped by `maxStock`); `'refused'` means nothing changed
|
|
62
|
+
* (invalid quantity, or the product is out of stock).
|
|
63
|
+
*/
|
|
64
|
+
type AddItemStatus = 'added' | 'clamped' | 'refused';
|
|
65
|
+
interface AddItemOutcome {
|
|
66
|
+
status: AddItemStatus;
|
|
67
|
+
/** The resulting line quantity after the call (`0` when refused and no line exists). */
|
|
68
|
+
quantity: number;
|
|
69
|
+
}
|
|
70
|
+
interface CartStore {
|
|
71
|
+
items: CartItem[];
|
|
72
|
+
addItem: (item: CartItem) => AddItemOutcome;
|
|
73
|
+
removeItem: (productId: string, variantId?: string | null) => void;
|
|
74
|
+
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
75
|
+
clearCart: () => void;
|
|
76
|
+
getTotal: () => number;
|
|
77
|
+
getItemCount: () => number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* REB-837 cross-entry singleton guard — the mutation that caught it: a
|
|
81
|
+
* single test rendering `useShopOrderStatus` (the `./shop` tsup entry) and
|
|
82
|
+
* asserting on `useCartStore` (the `./cart` entry) saw the order resolve to
|
|
83
|
+
* `paid` but the cart stay non-empty. Root cause: `splitting: false` (R4,
|
|
84
|
+
* `tsup.config.ts`) bundles each entry standalone, so a plain relative
|
|
85
|
+
* import of this file from `./shop` INLINES a SEPARATE copy of this
|
|
86
|
+
* `create(...)` call — a second, disconnected store instance with its own
|
|
87
|
+
* state, not the one `@reblu/site-next/cart` consumers read. `clearCart()`
|
|
88
|
+
* from inside the hook silently cleared the WRONG store.
|
|
89
|
+
*
|
|
90
|
+
* `globalThis` keyed by a `Symbol.for(...)` (same registry entry regardless
|
|
91
|
+
* of which bundle's copy of this module evaluates it first) makes every
|
|
92
|
+
* entry that imports this file — however many times esbuild inlines it —
|
|
93
|
+
* resolve to the SAME store. Verified by mutation: removing this guard
|
|
94
|
+
* (reverting to a bare `export const useCartStore = create(...)`) turns
|
|
95
|
+
* `use-shop-order-status.test.ts`'s cart-clearing assertions red again once
|
|
96
|
+
* the hook is exercised through its OWN entry rather than imported directly
|
|
97
|
+
* within `cart`'s own test file (which never saw the bug, since it only
|
|
98
|
+
* ever touches ONE copy of this module).
|
|
99
|
+
*/
|
|
100
|
+
declare function createCartStore(): zustand.UseBoundStore<Omit<zustand.StoreApi<CartStore>, "setState" | "persist"> & {
|
|
101
|
+
setState(partial: CartStore | Partial<CartStore> | ((state: CartStore) => CartStore | Partial<CartStore>), replace?: false | undefined): unknown;
|
|
102
|
+
setState(state: CartStore | ((state: CartStore) => CartStore), replace: true): unknown;
|
|
103
|
+
persist: {
|
|
104
|
+
setOptions: (options: Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>) => void;
|
|
105
|
+
clearStorage: () => void;
|
|
106
|
+
rehydrate: () => Promise<void> | void;
|
|
107
|
+
hasHydrated: () => boolean;
|
|
108
|
+
onHydrate: (fn: (state: CartStore) => void) => () => void;
|
|
109
|
+
onFinishHydration: (fn: (state: CartStore) => void) => () => void;
|
|
110
|
+
getOptions: () => Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>;
|
|
111
|
+
};
|
|
112
|
+
}>;
|
|
113
|
+
type CartStoreApi = ReturnType<typeof createCartStore>;
|
|
114
|
+
declare const useCartStore: CartStoreApi;
|
|
115
|
+
/**
|
|
116
|
+
* TEST-ONLY (REB-837 review fix). Clears the `globalThis`-keyed slot so the
|
|
117
|
+
* NEXT access to `useCartStore` — in THIS module instance — creates a fresh
|
|
118
|
+
* store. The singleton is intentionally shared process-wide; most tests
|
|
119
|
+
* should keep resetting STATE via `useCartStore.setState(...)` in
|
|
120
|
+
* `beforeEach`, not the instance itself. Reach for this only to prove
|
|
121
|
+
* something about the singleton mechanism (e.g. the cross-entry test in
|
|
122
|
+
* `tests/cart-store-cross-entry-singleton.test.ts`) — it does not and
|
|
123
|
+
* cannot affect an ALREADY-evaluated copy of this module inlined into a
|
|
124
|
+
* different tsup entry's bundle (each such copy owns its own `const
|
|
125
|
+
* globalForCartStore`/`CART_STORE_GLOBAL_KEY` closed over at that copy's
|
|
126
|
+
* own module-evaluation time, same as any other ES module binding).
|
|
127
|
+
*/
|
|
128
|
+
declare function __resetCartStoreForTests(): void;
|
|
129
|
+
|
|
130
|
+
interface UseCartResult {
|
|
131
|
+
items: CartItem[];
|
|
132
|
+
/** Sum of every line's `unitPrice * quantity`, in integer cents. */
|
|
133
|
+
total: number;
|
|
134
|
+
/** Sum of every line's `quantity`. */
|
|
135
|
+
itemCount: number;
|
|
136
|
+
/**
|
|
137
|
+
* Whether `CartProvider`'s `persist.rehydrate()` has finished (REB-837).
|
|
138
|
+
* `false` on the server and on the very first client render — `items`
|
|
139
|
+
* (and therefore `itemCount`) jumps from `0` to whatever was persisted the
|
|
140
|
+
* instant hydration finishes, which is NOT a real "item added" event. A
|
|
141
|
+
* consumer driving `CartIcon`'s `bumpOnAdd` MUST gate the bump on this
|
|
142
|
+
* flag (see `CartIcon`'s own guard) or every page load with a non-empty
|
|
143
|
+
* cart bumps the badge once for free.
|
|
144
|
+
*/
|
|
145
|
+
hydrated: boolean;
|
|
146
|
+
addItem: (item: CartItem) => AddItemOutcome;
|
|
147
|
+
removeItem: (productId: string, variantId?: string | null) => void;
|
|
148
|
+
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
149
|
+
clearCart: () => void;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* `useCartHydrated` — standalone so a SITE that only needs the flag (e.g. to
|
|
153
|
+
* gate its own effects) doesn't have to subscribe to the whole cart via
|
|
154
|
+
* `useCart()`.
|
|
155
|
+
*/
|
|
156
|
+
declare function useCartHydrated(): boolean;
|
|
157
|
+
/**
|
|
158
|
+
* `useCart` — the ergonomic entry point for a SITE's cart UI: a single
|
|
159
|
+
* selector call that re-renders only on `items` changes, with `total`/
|
|
160
|
+
* `itemCount` derived inline (cheap — a plain `reduce` over a typically-small
|
|
161
|
+
* array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`
|
|
162
|
+
* selectors, which are stable function references and would NOT trigger a
|
|
163
|
+
* re-render on their own. A SITE that wants raw store access (existing
|
|
164
|
+
* per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still
|
|
165
|
+
* import `useCartStore` directly — this hook doesn't replace it.
|
|
166
|
+
*/
|
|
167
|
+
declare function useCart(): UseCartResult;
|
|
168
|
+
|
|
169
|
+
export { type AddItemOutcome, type AddItemStatus, CART_STORAGE_KEY, CART_STORE_PERSIST_VERSION, type CartItem, CartProvider, type CartStore, type UseCartResult, __resetCartStoreForTests, useCart, useCartHydrated, useCartStore };
|
package/dist/cart.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
var CART_STORE_PERSIST_VERSION = 0;
|
|
11
|
+
function isSameItem(a, productId, variantId) {
|
|
12
|
+
return a.productId === productId && (a.variantId ?? null) === (variantId ?? null);
|
|
13
|
+
}
|
|
14
|
+
function normalizeQuantity(quantity, maxStock) {
|
|
15
|
+
if (!Number.isFinite(quantity)) return 0;
|
|
16
|
+
const truncated = Math.trunc(quantity);
|
|
17
|
+
if (truncated < 1) return 0;
|
|
18
|
+
if (maxStock === null) return truncated;
|
|
19
|
+
const maxTruncated = Math.trunc(maxStock);
|
|
20
|
+
if (maxTruncated < 1) return 0;
|
|
21
|
+
return Math.min(truncated, maxTruncated);
|
|
22
|
+
}
|
|
23
|
+
function createCartStore() {
|
|
24
|
+
return create()(
|
|
25
|
+
persist(
|
|
26
|
+
(set, get) => ({
|
|
27
|
+
items: [],
|
|
28
|
+
addItem: (item) => {
|
|
29
|
+
let outcome = { status: "refused", quantity: 0 };
|
|
30
|
+
set((state) => {
|
|
31
|
+
const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId));
|
|
32
|
+
if (existing) {
|
|
33
|
+
if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {
|
|
34
|
+
outcome = { status: "refused", quantity: 0 };
|
|
35
|
+
return {
|
|
36
|
+
items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId))
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const requestedQty = existing.quantity + item.quantity;
|
|
40
|
+
const newQty = normalizeQuantity(requestedQty, item.maxStock);
|
|
41
|
+
if (newQty < 1) {
|
|
42
|
+
outcome = { status: "refused", quantity: existing.quantity };
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
outcome = {
|
|
46
|
+
status: newQty < requestedQty ? "clamped" : "added",
|
|
47
|
+
quantity: newQty
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
items: state.items.map(
|
|
51
|
+
(i) => isSameItem(i, item.productId, item.variantId) ? { ...i, quantity: newQty, maxStock: item.maxStock } : i
|
|
52
|
+
)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const qty = normalizeQuantity(item.quantity, item.maxStock);
|
|
56
|
+
if (qty < 1) {
|
|
57
|
+
outcome = { status: "refused", quantity: 0 };
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
outcome = { status: qty < item.quantity ? "clamped" : "added", quantity: qty };
|
|
61
|
+
return { items: [...state.items, { ...item, quantity: qty }] };
|
|
62
|
+
});
|
|
63
|
+
return outcome;
|
|
64
|
+
},
|
|
65
|
+
removeItem: (productId, variantId) => {
|
|
66
|
+
set((state) => ({
|
|
67
|
+
items: state.items.filter((i) => !isSameItem(i, productId, variantId))
|
|
68
|
+
}));
|
|
69
|
+
},
|
|
70
|
+
updateQuantity: (productId, variantId, quantity) => {
|
|
71
|
+
const existing = get().items.find((i) => isSameItem(i, productId, variantId));
|
|
72
|
+
if (!existing) return;
|
|
73
|
+
const newQty = normalizeQuantity(quantity, existing.maxStock);
|
|
74
|
+
if (newQty < 1) {
|
|
75
|
+
get().removeItem(productId, variantId);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
set((state) => ({
|
|
79
|
+
items: state.items.map(
|
|
80
|
+
(i) => isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i
|
|
81
|
+
)
|
|
82
|
+
}));
|
|
83
|
+
},
|
|
84
|
+
clearCart: () => set({ items: [] }),
|
|
85
|
+
getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),
|
|
86
|
+
getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0)
|
|
87
|
+
}),
|
|
88
|
+
{
|
|
89
|
+
name: CART_STORAGE_KEY,
|
|
90
|
+
version: CART_STORE_PERSIST_VERSION,
|
|
91
|
+
skipHydration: true
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
var CART_STORE_GLOBAL_KEY = /* @__PURE__ */ Symbol.for(
|
|
97
|
+
`@reblu/site-next/cart-store@v${CART_STORE_PERSIST_VERSION}`
|
|
98
|
+
);
|
|
99
|
+
var globalForCartStore = globalThis;
|
|
100
|
+
var useCartStore = globalForCartStore[CART_STORE_GLOBAL_KEY] ?? (globalForCartStore[CART_STORE_GLOBAL_KEY] = createCartStore());
|
|
101
|
+
function __resetCartStoreForTests() {
|
|
102
|
+
delete globalForCartStore[CART_STORE_GLOBAL_KEY];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/cart/provider.tsx
|
|
106
|
+
import { Fragment, jsx } from "react/jsx-runtime";
|
|
107
|
+
function CartProvider({ children }) {
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
useCartStore.persist.rehydrate();
|
|
110
|
+
}, []);
|
|
111
|
+
return /* @__PURE__ */ jsx(Fragment, { children });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/cart/use-cart.ts
|
|
115
|
+
import { useSyncExternalStore } from "react";
|
|
116
|
+
function subscribeHydration(onStoreChange) {
|
|
117
|
+
return useCartStore.persist.onFinishHydration(onStoreChange);
|
|
118
|
+
}
|
|
119
|
+
function getHydratedSnapshot() {
|
|
120
|
+
return useCartStore.persist.hasHydrated();
|
|
121
|
+
}
|
|
122
|
+
function getServerHydratedSnapshot() {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
function useCartHydrated() {
|
|
126
|
+
return useSyncExternalStore(subscribeHydration, getHydratedSnapshot, getServerHydratedSnapshot);
|
|
127
|
+
}
|
|
128
|
+
function useCart() {
|
|
129
|
+
const items = useCartStore((s) => s.items);
|
|
130
|
+
const addItem = useCartStore((s) => s.addItem);
|
|
131
|
+
const removeItem = useCartStore((s) => s.removeItem);
|
|
132
|
+
const updateQuantity = useCartStore((s) => s.updateQuantity);
|
|
133
|
+
const clearCart = useCartStore((s) => s.clearCart);
|
|
134
|
+
const hydrated = useCartHydrated();
|
|
135
|
+
const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
|
136
|
+
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
|
137
|
+
return { items, total, itemCount, hydrated, addItem, removeItem, updateQuantity, clearCart };
|
|
138
|
+
}
|
|
139
|
+
export {
|
|
140
|
+
CART_STORAGE_KEY,
|
|
141
|
+
CART_STORE_PERSIST_VERSION,
|
|
142
|
+
CartProvider,
|
|
143
|
+
__resetCartStoreForTests,
|
|
144
|
+
useCart,
|
|
145
|
+
useCartHydrated,
|
|
146
|
+
useCartStore
|
|
147
|
+
};
|
|
148
|
+
//# 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\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":"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","names":[]}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { ShopProductsParams, ListResult, RebluClient } from '@reblu/site-client';
|
|
2
|
+
import { ShopProductListItem, ShopFacets } from '@reblu/site-contracts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`
|
|
6
|
+
* from `next/navigation`, or a plain `new URLSearchParams(record)`) into
|
|
7
|
+
* `ShopProductsParams`. Hostile/unknown params are dropped: only the known
|
|
8
|
+
* scalar keys and the two bracket groups are ever read.
|
|
9
|
+
*/
|
|
10
|
+
declare function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams;
|
|
11
|
+
/**
|
|
12
|
+
* Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of
|
|
13
|
+
* {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so
|
|
14
|
+
* `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`
|
|
15
|
+
* for any already-canonical (non-empty-string, in-range) input — that is the
|
|
16
|
+
* property the round-trip tests pin.
|
|
17
|
+
*/
|
|
18
|
+
declare function serializeShopParams(params: ShopProductsParams): URLSearchParams;
|
|
19
|
+
/**
|
|
20
|
+
* Convert Next's `searchParams` route prop —
|
|
21
|
+
* `Record<string, string | string[] | undefined>`, already URL-decoded, with
|
|
22
|
+
* a repeated query key grouped into an array — into a `URLSearchParams` for
|
|
23
|
+
* {@link parseShopSearchParams}.
|
|
24
|
+
*
|
|
25
|
+
* NOT the same as `new URLSearchParams(searchParams)`: the native
|
|
26
|
+
* `URLSearchParams` constructor only accepts `Record<string, string>` —
|
|
27
|
+
* handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls
|
|
28
|
+
* `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two
|
|
29
|
+
* repeated `attr[color]=red` / `attr[color]=blue` entries. That silently
|
|
30
|
+
* breaks `parseShopSearchParams`'s repeat-count-based array/scalar
|
|
31
|
+
* distinction (see its own module doc) for every multi-value `attr[<key>]`
|
|
32
|
+
* filter — this helper is what actually preserves the repeat.
|
|
33
|
+
*/
|
|
34
|
+
declare function toURLSearchParams(searchParams: Record<string, string | string[] | undefined>): URLSearchParams;
|
|
35
|
+
/**
|
|
36
|
+
* The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept
|
|
37
|
+
* structurally identical to its `ShopFiltersHrefs` type WITHOUT a package
|
|
38
|
+
* dependency on `@reblu/site-blocks` (this package has none today, and this
|
|
39
|
+
* one shape does not warrant adding one). Any object matching this shape is
|
|
40
|
+
* assignable to `ShopFiltersHrefs` by structural typing.
|
|
41
|
+
*/
|
|
42
|
+
interface ShopFilterHrefs {
|
|
43
|
+
taxonomy: (axisKey: string, value: string | undefined) => string;
|
|
44
|
+
attr: (fieldKey: string, value: string) => string;
|
|
45
|
+
price: (min: number | undefined, max: number | undefined) => string;
|
|
46
|
+
onSale: (nextActive: boolean) => string;
|
|
47
|
+
clearAll: () => string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build `ShopFilters`' href set from the CURRENT params and pathname. Every
|
|
51
|
+
* href preserves every OTHER current param (category, q, sort, every other
|
|
52
|
+
* taxonomy/attr filter) and resets pagination (`page`) — a filter change is
|
|
53
|
+
* never silently stuck on page 4 of a now-smaller result set.
|
|
54
|
+
*
|
|
55
|
+
* Pure and framework-agnostic (no `next/navigation`): a Server Component calls
|
|
56
|
+
* it directly with its own `searchParams` prop and route pathname — no
|
|
57
|
+
* `'use client'` boundary required. See `./use-shop-filter-links` for the
|
|
58
|
+
* client-side hook that wraps this with `usePathname`/`useSearchParams`.
|
|
59
|
+
*/
|
|
60
|
+
declare function buildShopFilterHrefs(current: ShopProductsParams, pathname: string): ShopFilterHrefs;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `getShopCollection` (REB-809) — fetches the product list AND its facets with
|
|
64
|
+
* the EXACT SAME params in one call. This is the seam that keeps a shop
|
|
65
|
+
* collection page correct: facet counts must reflect the same filter context
|
|
66
|
+
* the product list is showing, and a hand-rolled page that calls
|
|
67
|
+
* `client.shop.products(params)` and `client.shop.facets(someOtherParams)`
|
|
68
|
+
* separately is exactly how they drift apart.
|
|
69
|
+
*
|
|
70
|
+
* Server-safe (no `'use client'`, no React) — call it directly from an async
|
|
71
|
+
* Server Component (route `page.tsx`) with the params parsed via
|
|
72
|
+
* {@link parseShopSearchParams} from that page's `searchParams` prop.
|
|
73
|
+
*/
|
|
74
|
+
interface ShopCollection {
|
|
75
|
+
products: ListResult<ShopProductListItem>;
|
|
76
|
+
facets: ShopFacets;
|
|
77
|
+
/**
|
|
78
|
+
* R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;
|
|
79
|
+
* `'unavailable'` when it failed and `facets` above is the degraded
|
|
80
|
+
* {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an
|
|
81
|
+
* empty `facets` as "this tenant configured no filterable axes" when it may
|
|
82
|
+
* really mean "the facets endpoint is down right now". A logged
|
|
83
|
+
* `console.warn` (see below) covers the case nobody checks this field.
|
|
84
|
+
*/
|
|
85
|
+
facetsStatus: 'ok' | 'unavailable';
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* R4-facets-failure-takes-down-listing: `products` and `facets` are NOT
|
|
89
|
+
* equally critical. A product listing with no products is not a listing —
|
|
90
|
+
* that failure must propagate so the caller's own error handling (or a
|
|
91
|
+
* thrown-away `try/catch` degrading the whole page) sees it. A product
|
|
92
|
+
* listing with no FACETS is still a perfectly good listing, just without
|
|
93
|
+
* filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead
|
|
94
|
+
* of letting one upstream endpoint's outage take the other down too, is what
|
|
95
|
+
* `Promise.allSettled` (rather than `Promise.all`) buys here.
|
|
96
|
+
*
|
|
97
|
+
* R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged
|
|
98
|
+
* `[getShopCollection]` — the same convention `@reblu/site-next` already uses
|
|
99
|
+
* elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,
|
|
100
|
+
* `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the
|
|
101
|
+
* returned `facetsStatus: 'unavailable'` lets a caller that wants to act on
|
|
102
|
+
* it (retry, surface a banner, alert) do so without parsing a log line.
|
|
103
|
+
*/
|
|
104
|
+
declare function getShopCollection(client: RebluClient, params?: ShopProductsParams): Promise<ShopCollection>;
|
|
105
|
+
|
|
106
|
+
export { type ShopCollection as S, type ShopFilterHrefs as a, buildShopFilterHrefs as b, getShopCollection as g, parseShopSearchParams as p, serializeShopParams as s, toURLSearchParams as t };
|