@reblu/site-next 1.14.0 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/README.md +190 -1
- package/dist/cart.cjs +96 -53
- package/dist/cart.cjs.map +1 -1
- package/dist/cart.d.cts +82 -4
- package/dist/cart.d.ts +82 -4
- package/dist/cart.js +93 -53
- package/dist/cart.js.map +1 -1
- package/dist/{collection-BE5dhf-r.d.cts → collection-gAD_g03I.d.cts} +1 -1
- package/dist/{collection-BE5dhf-r.d.ts → collection-gAD_g03I.d.ts} +1 -1
- package/dist/route-handlers.cjs +8 -1
- package/dist/route-handlers.cjs.map +1 -1
- package/dist/route-handlers.d.cts +11 -0
- package/dist/route-handlers.d.ts +11 -0
- package/dist/route-handlers.js +8 -1
- package/dist/route-handlers.js.map +1 -1
- package/dist/shop-route-handlers.cjs +97 -2
- package/dist/shop-route-handlers.cjs.map +1 -1
- package/dist/shop-route-handlers.d.cts +70 -6
- package/dist/shop-route-handlers.d.ts +70 -6
- package/dist/shop-route-handlers.js +97 -2
- package/dist/shop-route-handlers.js.map +1 -1
- package/dist/shop-server.d.cts +1 -1
- package/dist/shop-server.d.ts +1 -1
- package/dist/shop.cjs +223 -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 +220 -1
- package/dist/shop.js.map +1 -1
- package/package.json +4 -4
package/dist/cart.d.cts
CHANGED
|
@@ -28,6 +28,16 @@ declare function CartProvider({ children }: {
|
|
|
28
28
|
* mount — the same explicit-hydration contract the SITE README documents.
|
|
29
29
|
*/
|
|
30
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;
|
|
31
41
|
interface CartItem {
|
|
32
42
|
productId: string;
|
|
33
43
|
variantId?: string | null;
|
|
@@ -43,16 +53,52 @@ interface CartItem {
|
|
|
43
53
|
taxRateBps?: number | null;
|
|
44
54
|
taxRateName?: string | null;
|
|
45
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`) while the line still grew; `'refused'`
|
|
62
|
+
* means nothing was added (invalid quantity, out of stock, or the line was
|
|
63
|
+
* already at `maxStock` — REB-839).
|
|
64
|
+
*/
|
|
65
|
+
type AddItemStatus = 'added' | 'clamped' | 'refused';
|
|
66
|
+
interface AddItemOutcome {
|
|
67
|
+
status: AddItemStatus;
|
|
68
|
+
/** The resulting line quantity after the call (`0` when refused and no line exists). */
|
|
69
|
+
quantity: number;
|
|
70
|
+
}
|
|
46
71
|
interface CartStore {
|
|
47
72
|
items: CartItem[];
|
|
48
|
-
addItem: (item: CartItem) =>
|
|
73
|
+
addItem: (item: CartItem) => AddItemOutcome;
|
|
49
74
|
removeItem: (productId: string, variantId?: string | null) => void;
|
|
50
75
|
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
51
76
|
clearCart: () => void;
|
|
52
77
|
getTotal: () => number;
|
|
53
78
|
getItemCount: () => number;
|
|
54
79
|
}
|
|
55
|
-
|
|
80
|
+
/**
|
|
81
|
+
* REB-837 cross-entry singleton guard — the mutation that caught it: a
|
|
82
|
+
* single test rendering `useShopOrderStatus` (the `./shop` tsup entry) and
|
|
83
|
+
* asserting on `useCartStore` (the `./cart` entry) saw the order resolve to
|
|
84
|
+
* `paid` but the cart stay non-empty. Root cause: `splitting: false` (R4,
|
|
85
|
+
* `tsup.config.ts`) bundles each entry standalone, so a plain relative
|
|
86
|
+
* import of this file from `./shop` INLINES a SEPARATE copy of this
|
|
87
|
+
* `create(...)` call — a second, disconnected store instance with its own
|
|
88
|
+
* state, not the one `@reblu/site-next/cart` consumers read. `clearCart()`
|
|
89
|
+
* from inside the hook silently cleared the WRONG store.
|
|
90
|
+
*
|
|
91
|
+
* `globalThis` keyed by a `Symbol.for(...)` (same registry entry regardless
|
|
92
|
+
* of which bundle's copy of this module evaluates it first) makes every
|
|
93
|
+
* entry that imports this file — however many times esbuild inlines it —
|
|
94
|
+
* resolve to the SAME store. Verified by mutation: removing this guard
|
|
95
|
+
* (reverting to a bare `export const useCartStore = create(...)`) turns
|
|
96
|
+
* `use-shop-order-status.test.ts`'s cart-clearing assertions red again once
|
|
97
|
+
* the hook is exercised through its OWN entry rather than imported directly
|
|
98
|
+
* within `cart`'s own test file (which never saw the bug, since it only
|
|
99
|
+
* ever touches ONE copy of this module).
|
|
100
|
+
*/
|
|
101
|
+
declare function createCartStore(): zustand.UseBoundStore<Omit<zustand.StoreApi<CartStore>, "setState" | "persist"> & {
|
|
56
102
|
setState(partial: CartStore | Partial<CartStore> | ((state: CartStore) => CartStore | Partial<CartStore>), replace?: false | undefined): unknown;
|
|
57
103
|
setState(state: CartStore | ((state: CartStore) => CartStore), replace: true): unknown;
|
|
58
104
|
persist: {
|
|
@@ -65,6 +111,22 @@ declare const useCartStore: zustand.UseBoundStore<Omit<zustand.StoreApi<CartStor
|
|
|
65
111
|
getOptions: () => Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>;
|
|
66
112
|
};
|
|
67
113
|
}>;
|
|
114
|
+
type CartStoreApi = ReturnType<typeof createCartStore>;
|
|
115
|
+
declare const useCartStore: CartStoreApi;
|
|
116
|
+
/**
|
|
117
|
+
* TEST-ONLY (REB-837 review fix). Clears the `globalThis`-keyed slot so the
|
|
118
|
+
* NEXT access to `useCartStore` — in THIS module instance — creates a fresh
|
|
119
|
+
* store. The singleton is intentionally shared process-wide; most tests
|
|
120
|
+
* should keep resetting STATE via `useCartStore.setState(...)` in
|
|
121
|
+
* `beforeEach`, not the instance itself. Reach for this only to prove
|
|
122
|
+
* something about the singleton mechanism (e.g. the cross-entry test in
|
|
123
|
+
* `tests/cart-store-cross-entry-singleton.test.ts`) — it does not and
|
|
124
|
+
* cannot affect an ALREADY-evaluated copy of this module inlined into a
|
|
125
|
+
* different tsup entry's bundle (each such copy owns its own `const
|
|
126
|
+
* globalForCartStore`/`CART_STORE_GLOBAL_KEY` closed over at that copy's
|
|
127
|
+
* own module-evaluation time, same as any other ES module binding).
|
|
128
|
+
*/
|
|
129
|
+
declare function __resetCartStoreForTests(): void;
|
|
68
130
|
|
|
69
131
|
interface UseCartResult {
|
|
70
132
|
items: CartItem[];
|
|
@@ -72,11 +134,27 @@ interface UseCartResult {
|
|
|
72
134
|
total: number;
|
|
73
135
|
/** Sum of every line's `quantity`. */
|
|
74
136
|
itemCount: number;
|
|
75
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Whether `CartProvider`'s `persist.rehydrate()` has finished (REB-837).
|
|
139
|
+
* `false` on the server and on the very first client render — `items`
|
|
140
|
+
* (and therefore `itemCount`) jumps from `0` to whatever was persisted the
|
|
141
|
+
* instant hydration finishes, which is NOT a real "item added" event. A
|
|
142
|
+
* consumer driving `CartIcon`'s `bumpOnAdd` MUST gate the bump on this
|
|
143
|
+
* flag (see `CartIcon`'s own guard) or every page load with a non-empty
|
|
144
|
+
* cart bumps the badge once for free.
|
|
145
|
+
*/
|
|
146
|
+
hydrated: boolean;
|
|
147
|
+
addItem: (item: CartItem) => AddItemOutcome;
|
|
76
148
|
removeItem: (productId: string, variantId?: string | null) => void;
|
|
77
149
|
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
78
150
|
clearCart: () => void;
|
|
79
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* `useCartHydrated` — standalone so a SITE that only needs the flag (e.g. to
|
|
154
|
+
* gate its own effects) doesn't have to subscribe to the whole cart via
|
|
155
|
+
* `useCart()`.
|
|
156
|
+
*/
|
|
157
|
+
declare function useCartHydrated(): boolean;
|
|
80
158
|
/**
|
|
81
159
|
* `useCart` — the ergonomic entry point for a SITE's cart UI: a single
|
|
82
160
|
* selector call that re-renders only on `items` changes, with `total`/
|
|
@@ -89,4 +167,4 @@ interface UseCartResult {
|
|
|
89
167
|
*/
|
|
90
168
|
declare function useCart(): UseCartResult;
|
|
91
169
|
|
|
92
|
-
export { CART_STORAGE_KEY, type CartItem, CartProvider, type CartStore, type UseCartResult, useCart, useCartStore };
|
|
170
|
+
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
CHANGED
|
@@ -28,6 +28,16 @@ declare function CartProvider({ children }: {
|
|
|
28
28
|
* mount — the same explicit-hydration contract the SITE README documents.
|
|
29
29
|
*/
|
|
30
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;
|
|
31
41
|
interface CartItem {
|
|
32
42
|
productId: string;
|
|
33
43
|
variantId?: string | null;
|
|
@@ -43,16 +53,52 @@ interface CartItem {
|
|
|
43
53
|
taxRateBps?: number | null;
|
|
44
54
|
taxRateName?: string | null;
|
|
45
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`) while the line still grew; `'refused'`
|
|
62
|
+
* means nothing was added (invalid quantity, out of stock, or the line was
|
|
63
|
+
* already at `maxStock` — REB-839).
|
|
64
|
+
*/
|
|
65
|
+
type AddItemStatus = 'added' | 'clamped' | 'refused';
|
|
66
|
+
interface AddItemOutcome {
|
|
67
|
+
status: AddItemStatus;
|
|
68
|
+
/** The resulting line quantity after the call (`0` when refused and no line exists). */
|
|
69
|
+
quantity: number;
|
|
70
|
+
}
|
|
46
71
|
interface CartStore {
|
|
47
72
|
items: CartItem[];
|
|
48
|
-
addItem: (item: CartItem) =>
|
|
73
|
+
addItem: (item: CartItem) => AddItemOutcome;
|
|
49
74
|
removeItem: (productId: string, variantId?: string | null) => void;
|
|
50
75
|
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
51
76
|
clearCart: () => void;
|
|
52
77
|
getTotal: () => number;
|
|
53
78
|
getItemCount: () => number;
|
|
54
79
|
}
|
|
55
|
-
|
|
80
|
+
/**
|
|
81
|
+
* REB-837 cross-entry singleton guard — the mutation that caught it: a
|
|
82
|
+
* single test rendering `useShopOrderStatus` (the `./shop` tsup entry) and
|
|
83
|
+
* asserting on `useCartStore` (the `./cart` entry) saw the order resolve to
|
|
84
|
+
* `paid` but the cart stay non-empty. Root cause: `splitting: false` (R4,
|
|
85
|
+
* `tsup.config.ts`) bundles each entry standalone, so a plain relative
|
|
86
|
+
* import of this file from `./shop` INLINES a SEPARATE copy of this
|
|
87
|
+
* `create(...)` call — a second, disconnected store instance with its own
|
|
88
|
+
* state, not the one `@reblu/site-next/cart` consumers read. `clearCart()`
|
|
89
|
+
* from inside the hook silently cleared the WRONG store.
|
|
90
|
+
*
|
|
91
|
+
* `globalThis` keyed by a `Symbol.for(...)` (same registry entry regardless
|
|
92
|
+
* of which bundle's copy of this module evaluates it first) makes every
|
|
93
|
+
* entry that imports this file — however many times esbuild inlines it —
|
|
94
|
+
* resolve to the SAME store. Verified by mutation: removing this guard
|
|
95
|
+
* (reverting to a bare `export const useCartStore = create(...)`) turns
|
|
96
|
+
* `use-shop-order-status.test.ts`'s cart-clearing assertions red again once
|
|
97
|
+
* the hook is exercised through its OWN entry rather than imported directly
|
|
98
|
+
* within `cart`'s own test file (which never saw the bug, since it only
|
|
99
|
+
* ever touches ONE copy of this module).
|
|
100
|
+
*/
|
|
101
|
+
declare function createCartStore(): zustand.UseBoundStore<Omit<zustand.StoreApi<CartStore>, "setState" | "persist"> & {
|
|
56
102
|
setState(partial: CartStore | Partial<CartStore> | ((state: CartStore) => CartStore | Partial<CartStore>), replace?: false | undefined): unknown;
|
|
57
103
|
setState(state: CartStore | ((state: CartStore) => CartStore), replace: true): unknown;
|
|
58
104
|
persist: {
|
|
@@ -65,6 +111,22 @@ declare const useCartStore: zustand.UseBoundStore<Omit<zustand.StoreApi<CartStor
|
|
|
65
111
|
getOptions: () => Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>;
|
|
66
112
|
};
|
|
67
113
|
}>;
|
|
114
|
+
type CartStoreApi = ReturnType<typeof createCartStore>;
|
|
115
|
+
declare const useCartStore: CartStoreApi;
|
|
116
|
+
/**
|
|
117
|
+
* TEST-ONLY (REB-837 review fix). Clears the `globalThis`-keyed slot so the
|
|
118
|
+
* NEXT access to `useCartStore` — in THIS module instance — creates a fresh
|
|
119
|
+
* store. The singleton is intentionally shared process-wide; most tests
|
|
120
|
+
* should keep resetting STATE via `useCartStore.setState(...)` in
|
|
121
|
+
* `beforeEach`, not the instance itself. Reach for this only to prove
|
|
122
|
+
* something about the singleton mechanism (e.g. the cross-entry test in
|
|
123
|
+
* `tests/cart-store-cross-entry-singleton.test.ts`) — it does not and
|
|
124
|
+
* cannot affect an ALREADY-evaluated copy of this module inlined into a
|
|
125
|
+
* different tsup entry's bundle (each such copy owns its own `const
|
|
126
|
+
* globalForCartStore`/`CART_STORE_GLOBAL_KEY` closed over at that copy's
|
|
127
|
+
* own module-evaluation time, same as any other ES module binding).
|
|
128
|
+
*/
|
|
129
|
+
declare function __resetCartStoreForTests(): void;
|
|
68
130
|
|
|
69
131
|
interface UseCartResult {
|
|
70
132
|
items: CartItem[];
|
|
@@ -72,11 +134,27 @@ interface UseCartResult {
|
|
|
72
134
|
total: number;
|
|
73
135
|
/** Sum of every line's `quantity`. */
|
|
74
136
|
itemCount: number;
|
|
75
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Whether `CartProvider`'s `persist.rehydrate()` has finished (REB-837).
|
|
139
|
+
* `false` on the server and on the very first client render — `items`
|
|
140
|
+
* (and therefore `itemCount`) jumps from `0` to whatever was persisted the
|
|
141
|
+
* instant hydration finishes, which is NOT a real "item added" event. A
|
|
142
|
+
* consumer driving `CartIcon`'s `bumpOnAdd` MUST gate the bump on this
|
|
143
|
+
* flag (see `CartIcon`'s own guard) or every page load with a non-empty
|
|
144
|
+
* cart bumps the badge once for free.
|
|
145
|
+
*/
|
|
146
|
+
hydrated: boolean;
|
|
147
|
+
addItem: (item: CartItem) => AddItemOutcome;
|
|
76
148
|
removeItem: (productId: string, variantId?: string | null) => void;
|
|
77
149
|
updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
|
|
78
150
|
clearCart: () => void;
|
|
79
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* `useCartHydrated` — standalone so a SITE that only needs the flag (e.g. to
|
|
154
|
+
* gate its own effects) doesn't have to subscribe to the whole cart via
|
|
155
|
+
* `useCart()`.
|
|
156
|
+
*/
|
|
157
|
+
declare function useCartHydrated(): boolean;
|
|
80
158
|
/**
|
|
81
159
|
* `useCart` — the ergonomic entry point for a SITE's cart UI: a single
|
|
82
160
|
* selector call that re-renders only on `items` changes, with `total`/
|
|
@@ -89,4 +167,4 @@ interface UseCartResult {
|
|
|
89
167
|
*/
|
|
90
168
|
declare function useCart(): UseCartResult;
|
|
91
169
|
|
|
92
|
-
export { CART_STORAGE_KEY, type CartItem, CartProvider, type CartStore, type UseCartResult, useCart, useCartStore };
|
|
170
|
+
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
CHANGED
|
@@ -7,6 +7,7 @@ import { useEffect } from "react";
|
|
|
7
7
|
import { create } from "zustand";
|
|
8
8
|
import { persist } from "zustand/middleware";
|
|
9
9
|
var CART_STORAGE_KEY = "shop-cart";
|
|
10
|
+
var CART_STORE_PERSIST_VERSION = 0;
|
|
10
11
|
function isSameItem(a, productId, variantId) {
|
|
11
12
|
return a.productId === productId && (a.variantId ?? null) === (variantId ?? null);
|
|
12
13
|
}
|
|
@@ -19,65 +20,87 @@ function normalizeQuantity(quantity, maxStock) {
|
|
|
19
20
|
if (maxTruncated < 1) return 0;
|
|
20
21
|
return Math.min(truncated, maxTruncated);
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 <= existing.quantity ? "refused" : newQty < requestedQty ? "clamped" : "added",
|
|
47
|
+
quantity: newQty
|
|
48
|
+
};
|
|
31
49
|
return {
|
|
32
|
-
items: state.items.
|
|
50
|
+
items: state.items.map(
|
|
51
|
+
(i) => isSameItem(i, item.productId, item.variantId) ? { ...i, quantity: newQty, maxStock: item.maxStock } : i
|
|
52
|
+
)
|
|
33
53
|
};
|
|
34
54
|
}
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
55
|
+
const qty = normalizeQuantity(item.quantity, item.maxStock);
|
|
56
|
+
if (qty < 1) {
|
|
57
|
+
outcome = { status: "refused", quantity: 0 };
|
|
37
58
|
return {};
|
|
38
59
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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;
|
|
48
77
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
)
|
|
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}`
|
|
80
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
|
+
}
|
|
81
104
|
|
|
82
105
|
// src/cart/provider.tsx
|
|
83
106
|
import { Fragment, jsx } from "react/jsx-runtime";
|
|
@@ -89,20 +112,37 @@ function CartProvider({ children }) {
|
|
|
89
112
|
}
|
|
90
113
|
|
|
91
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
|
+
}
|
|
92
128
|
function useCart() {
|
|
93
129
|
const items = useCartStore((s) => s.items);
|
|
94
130
|
const addItem = useCartStore((s) => s.addItem);
|
|
95
131
|
const removeItem = useCartStore((s) => s.removeItem);
|
|
96
132
|
const updateQuantity = useCartStore((s) => s.updateQuantity);
|
|
97
133
|
const clearCart = useCartStore((s) => s.clearCart);
|
|
134
|
+
const hydrated = useCartHydrated();
|
|
98
135
|
const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
|
99
136
|
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
|
100
|
-
return { items, total, itemCount, addItem, removeItem, updateQuantity, clearCart };
|
|
137
|
+
return { items, total, itemCount, hydrated, addItem, removeItem, updateQuantity, clearCart };
|
|
101
138
|
}
|
|
102
139
|
export {
|
|
103
140
|
CART_STORAGE_KEY,
|
|
141
|
+
CART_STORE_PERSIST_VERSION,
|
|
104
142
|
CartProvider,
|
|
143
|
+
__resetCartStoreForTests,
|
|
105
144
|
useCart,
|
|
145
|
+
useCartHydrated,
|
|
106
146
|
useCartStore
|
|
107
147
|
};
|
|
108
148
|
//# sourceMappingURL=cart.js.map
|
package/dist/cart.js.map
CHANGED
|
@@ -1 +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":[]}
|
|
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`) while the line still grew; `'refused'`\n * means nothing was added (invalid quantity, out of stock, or the line was\n * already at `maxStock` — REB-839).\n */\nexport type AddItemStatus = 'added' | 'clamped' | 'refused'\n\nexport interface AddItemOutcome {\n status: AddItemStatus\n /** The resulting line quantity after the call (`0` when refused and no line exists). */\n quantity: number\n}\n\nexport interface CartStore {\n items: CartItem[]\n addItem: (item: CartItem) => AddItemOutcome\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n getTotal: () => number\n getItemCount: () => number\n}\n\nfunction isSameItem(a: CartItem, productId: string, variantId?: string | null): boolean {\n return a.productId === productId && (a.variantId ?? null) === (variantId ?? null)\n}\n\n/**\n * REB-829 review finding: neither `addItem` nor `updateQuantity` validated\n * `quantity` — `0`, a negative number, `NaN` (e.g. a stray `parseInt('')`\n * upstream), a non-integer, or `maxStock` itself being `0` (out of stock)\n * all used to leave an invalid line sitting in the cart, silently reaching\n * checkout.\n *\n * Normalizes to a safe POSITIVE INTEGER clamped to `maxStock`. Returns `0`\n * to signal \"not a valid positive quantity\" — callers decide what that\n * means for them: `addItem` ignores the call (never creates/corrupts a\n * line), `updateQuantity` removes the line (same as its pre-existing\n * `quantity <= 0` → remove contract, just now reached by more inputs).\n *\n * `Math.trunc` rounds toward zero (not `Math.floor`, which would round a\n * negative input further negative before the `< 1` check below ever saw it\n * — not a behavioural difference here since any negative result is\n * rejected either way, but `trunc` is the operation that actually means\n * \"drop the fractional part\").\n */\nfunction normalizeQuantity(quantity: number, maxStock: number | null): number {\n if (!Number.isFinite(quantity)) return 0\n const truncated = Math.trunc(quantity)\n if (truncated < 1) return 0\n if (maxStock === null) return truncated\n const maxTruncated = Math.trunc(maxStock)\n if (maxTruncated < 1) return 0\n return Math.min(truncated, maxTruncated)\n}\n\n/**\n * REB-837 cross-entry singleton guard — the mutation that caught it: a\n * single test rendering `useShopOrderStatus` (the `./shop` tsup entry) and\n * asserting on `useCartStore` (the `./cart` entry) saw the order resolve to\n * `paid` but the cart stay non-empty. Root cause: `splitting: false` (R4,\n * `tsup.config.ts`) bundles each entry standalone, so a plain relative\n * import of this file from `./shop` INLINES a SEPARATE copy of this\n * `create(...)` call — a second, disconnected store instance with its own\n * state, not the one `@reblu/site-next/cart` consumers read. `clearCart()`\n * from inside the hook silently cleared the WRONG store.\n *\n * `globalThis` keyed by a `Symbol.for(...)` (same registry entry regardless\n * of which bundle's copy of this module evaluates it first) makes every\n * entry that imports this file — however many times esbuild inlines it —\n * resolve to the SAME store. Verified by mutation: removing this guard\n * (reverting to a bare `export const useCartStore = create(...)`) turns\n * `use-shop-order-status.test.ts`'s cart-clearing assertions red again once\n * the hook is exercised through its OWN entry rather than imported directly\n * within `cart`'s own test file (which never saw the bug, since it only\n * ever touches ONE copy of this module).\n */\nfunction createCartStore() {\n return create<CartStore>()(\n persist(\n (set, get) => ({\n items: [],\n\n addItem: (item) => {\n // REB-837: `set`'s updater can't return a value to the caller, so the\n // outcome is captured in this closure variable and returned after.\n let outcome: AddItemOutcome = { status: 'refused', quantity: 0 }\n\n set((state) => {\n const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId))\n\n if (existing) {\n // REB-829 review follow-up (a): incoming maxStock is the\n // FRESHEST stock signal available — if it says 0, the product is\n // confirmed out of stock right now, so the existing line is\n // removed outright (same contract as updateQuantity's own\n // \"quantity resolves to <1 → remove\"), not merely left alone.\n if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {\n outcome = { status: 'refused', quantity: 0 }\n return {\n items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId)),\n }\n }\n\n const requestedQty = existing.quantity + item.quantity\n const newQty = normalizeQuantity(requestedQty, item.maxStock)\n if (newQty < 1) {\n // Invalid delta (NaN/non-positive quantity) — leave the\n // existing, previously-valid line untouched rather than\n // destroying good data because of one bad call.\n outcome = { status: 'refused', quantity: existing.quantity }\n return {}\n }\n\n // REB-839: nothing was gained when stock caps the line at (or,\n // with fresher stock, below) what it already held — report that\n // as 'refused', never as a partial-success 'clamped'. The line is\n // still reconciled below to the fresher maxStock.\n outcome = {\n status:\n newQty <= existing.quantity\n ? 'refused'\n : newQty < requestedQty\n ? 'clamped'\n : 'added',\n quantity: newQty,\n }\n\n // Refresh maxStock with the incoming value on every successful\n // merge — the stale-maxStock finding: a stored line used to keep\n // its ORIGINAL maxStock forever, so a later updateQuantity call\n // clamped against stock data that could be long out of date.\n return {\n items: state.items.map((i) =>\n isSameItem(i, item.productId, item.variantId)\n ? { ...i, quantity: newQty, maxStock: item.maxStock }\n : i,\n ),\n }\n }\n\n const qty = normalizeQuantity(item.quantity, item.maxStock)\n if (qty < 1) {\n // Invalid quantity, or maxStock 0 (out of stock) — never create\n // a line nobody could actually buy.\n outcome = { status: 'refused', quantity: 0 }\n return {}\n }\n\n outcome = { status: qty < item.quantity ? 'clamped' : 'added', quantity: qty }\n return { items: [...state.items, { ...item, quantity: qty }] }\n })\n\n return outcome\n },\n\n removeItem: (productId, variantId) => {\n set((state) => ({\n items: state.items.filter((i) => !isSameItem(i, productId, variantId)),\n }))\n },\n\n updateQuantity: (productId, variantId, quantity) => {\n const existing = get().items.find((i) => isSameItem(i, productId, variantId))\n if (!existing) return\n\n const newQty = normalizeQuantity(quantity, existing.maxStock)\n if (newQty < 1) {\n get().removeItem(productId, variantId)\n return\n }\n\n set((state) => ({\n items: state.items.map((i) =>\n isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i,\n ),\n }))\n },\n\n clearCart: () => set({ items: [] }),\n\n getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),\n\n getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0),\n }),\n {\n name: CART_STORAGE_KEY,\n version: CART_STORE_PERSIST_VERSION,\n skipHydration: true,\n },\n ),\n )\n}\n\ntype CartStoreApi = ReturnType<typeof createCartStore>\n\n/**\n * Versioned (REB-837 review fix) with {@link CART_STORE_PERSIST_VERSION} —\n * an incompatible shape change bumps that version, which mints a NEW\n * `Symbol.for(...)` registry key, so a stale old-shape singleton (e.g. kept\n * alive across an HMR reload in dev before a code change takes effect)\n * never satisfies the new one.\n */\nconst CART_STORE_GLOBAL_KEY = Symbol.for(\n `@reblu/site-next/cart-store@v${CART_STORE_PERSIST_VERSION}`,\n)\n\ninterface GlobalWithCartStore {\n [CART_STORE_GLOBAL_KEY]?: CartStoreApi\n}\n\nconst globalForCartStore = globalThis as typeof globalThis & GlobalWithCartStore\n\nexport const useCartStore: CartStoreApi =\n globalForCartStore[CART_STORE_GLOBAL_KEY] ??\n (globalForCartStore[CART_STORE_GLOBAL_KEY] = createCartStore())\n\n/**\n * TEST-ONLY (REB-837 review fix). Clears the `globalThis`-keyed slot so the\n * NEXT access to `useCartStore` — in THIS module instance — creates a fresh\n * store. The singleton is intentionally shared process-wide; most tests\n * should keep resetting STATE via `useCartStore.setState(...)` in\n * `beforeEach`, not the instance itself. Reach for this only to prove\n * something about the singleton mechanism (e.g. the cross-entry test in\n * `tests/cart-store-cross-entry-singleton.test.ts`) — it does not and\n * cannot affect an ALREADY-evaluated copy of this module inlined into a\n * different tsup entry's bundle (each such copy owns its own `const\n * globalForCartStore`/`CART_STORE_GLOBAL_KEY` closed over at that copy's\n * own module-evaluation time, same as any other ES module binding).\n */\nexport function __resetCartStoreForTests(): void {\n delete globalForCartStore[CART_STORE_GLOBAL_KEY]\n}\n","'use client'\n\nimport { useSyncExternalStore } from 'react'\n\nimport { useCartStore, type AddItemOutcome, type CartItem } from './store'\n\nexport interface UseCartResult {\n items: CartItem[]\n /** Sum of every line's `unitPrice * quantity`, in integer cents. */\n total: number\n /** Sum of every line's `quantity`. */\n itemCount: number\n /**\n * Whether `CartProvider`'s `persist.rehydrate()` has finished (REB-837).\n * `false` on the server and on the very first client render — `items`\n * (and therefore `itemCount`) jumps from `0` to whatever was persisted the\n * instant hydration finishes, which is NOT a real \"item added\" event. A\n * consumer driving `CartIcon`'s `bumpOnAdd` MUST gate the bump on this\n * flag (see `CartIcon`'s own guard) or every page load with a non-empty\n * cart bumps the badge once for free.\n */\n hydrated: boolean\n addItem: (item: CartItem) => AddItemOutcome\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n}\n\nfunction subscribeHydration(onStoreChange: () => void): () => void {\n return useCartStore.persist.onFinishHydration(onStoreChange)\n}\n\nfunction getHydratedSnapshot(): boolean {\n return useCartStore.persist.hasHydrated()\n}\n\n/** Server snapshot is always `false` — the server never reads `localStorage`. */\nfunction getServerHydratedSnapshot(): boolean {\n return false\n}\n\n/**\n * `useCartHydrated` — standalone so a SITE that only needs the flag (e.g. to\n * gate its own effects) doesn't have to subscribe to the whole cart via\n * `useCart()`.\n */\nexport function useCartHydrated(): boolean {\n return useSyncExternalStore(subscribeHydration, getHydratedSnapshot, getServerHydratedSnapshot)\n}\n\n/**\n * `useCart` — the ergonomic entry point for a SITE's cart UI: a single\n * selector call that re-renders only on `items` changes, with `total`/\n * `itemCount` derived inline (cheap — a plain `reduce` over a typically-small\n * array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`\n * selectors, which are stable function references and would NOT trigger a\n * re-render on their own. A SITE that wants raw store access (existing\n * per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still\n * import `useCartStore` directly — this hook doesn't replace it.\n */\nexport function useCart(): UseCartResult {\n const items = useCartStore((s) => s.items)\n const addItem = useCartStore((s) => s.addItem)\n const removeItem = useCartStore((s) => s.removeItem)\n const updateQuantity = useCartStore((s) => s.updateQuantity)\n const clearCart = useCartStore((s) => s.clearCart)\n const hydrated = useCartHydrated()\n\n const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0)\n const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)\n\n return { items, total, itemCount, hydrated, addItem, removeItem, updateQuantity, clearCart }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -103,4 +103,4 @@ interface ShopCollection {
|
|
|
103
103
|
*/
|
|
104
104
|
declare function getShopCollection(client: RebluClient, params?: ShopProductsParams): Promise<ShopCollection>;
|
|
105
105
|
|
|
106
|
-
export { type
|
|
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 };
|
|
@@ -103,4 +103,4 @@ interface ShopCollection {
|
|
|
103
103
|
*/
|
|
104
104
|
declare function getShopCollection(client: RebluClient, params?: ShopProductsParams): Promise<ShopCollection>;
|
|
105
105
|
|
|
106
|
-
export { type
|
|
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 };
|
package/dist/route-handlers.cjs
CHANGED
|
@@ -63,7 +63,14 @@ function createProxyRoute(targetPath, opts = {}) {
|
|
|
63
63
|
const xri = request.headers.get("x-real-ip");
|
|
64
64
|
if (xri) forwardedHeaders["x-real-ip"] = xri;
|
|
65
65
|
const upperMethod = method.toUpperCase();
|
|
66
|
-
|
|
66
|
+
let body = upperMethod === "GET" || upperMethod === "HEAD" ? void 0 : await request.text();
|
|
67
|
+
if (body && opts.transformBody) {
|
|
68
|
+
try {
|
|
69
|
+
body = opts.transformBody(body, request);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error(`[createProxyRoute] transformBody threw for ${targetPath}:`, err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
67
74
|
const search = request.nextUrl?.search ?? "";
|
|
68
75
|
const controller = new AbortController();
|
|
69
76
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/route-handlers.ts"],"sourcesContent":["/**\n * `@reblu/site-next/route-handlers` — server-only, dependency-free Route\n * Handler factory (REB-510 / design doc \"site-auth-package\").\n *\n * This module MUST stay dependency-free (no `@reblu/site-ui`, no React): it is\n * imported directly by `app/api/*` Route Handler files, and its own tsup entry\n * carries NO `'use client'` directive (mirrors the `cache` subpath's R4\n * precedent for the opposite direction — see `tsup.config.ts`).\n *\n * Scope: this factory covers the `/api/site/auth/*` proxy shape specifically\n * (fixed target path, JSON body passthrough, optional `Set-Cookie` forward) —\n * the same shape as every one of demo-site's 8 auth Route Handlers. It is\n * modeled on `apps/demo-site/src/lib/api/proxy.ts` (`proxyToBaseApi`) but is\n * NOT a drop-in replacement for every one of that helper's call sites: it\n * does not support a per-request DYNAMIC `targetPath` (e.g.\n * `api/shop/order/[sessionId]/route.ts`'s `` `/api/site/shop/order/${id}` ``)\n * or an arbitrary extra-`headers` passthrough — both are genuinely out of\n * scope for REB-510 (auth only). demo-site's non-auth proxy routes keep using\n * `proxyToBaseApi` directly; only the 8 auth routes are candidates to adopt\n * this factory.\n *\n * export const POST = createProxyRoute('/api/site/auth/login')\n *\n * Deliberate behavior change vs the original (R6): `proxy.ts` reads\n * `REBLU_API_BASE_URL`/`REBLU_API_KEY` at MODULE LOAD. This factory reads them\n * LAZILY, inside the returned handler, on every request — `opts.baseUrl` /\n * `opts.apiKey` still take priority when provided.\n *\n * Why this factory reads `process.env` at all, unlike `@reblu/site-client`\n * (whose own docblock states \"the SDK never reads `process.env` itself\"):\n * `site-client` is instantiated ONCE by the consuming SITE, which already\n * owns and injects its env at that single call site. `createProxyRoute` is\n * the opposite shape on purpose — a ZERO-CONFIG one-liner meant to be called\n * once per Route Handler FILE (8+ files in a typical SITE), so requiring\n * every file to re-thread `baseUrl`/`apiKey` would defeat the \"one line\"\n * value proposition documented in the README. The env fallback is this\n * factory's deliberate ergonomic default; `opts.baseUrl`/`opts.apiKey` remain\n * available for a SITE that wants explicit, non-`process.env` control.\n *\n * Trust note on header forwarding: `X-Forwarded-For`/`X-Real-IP` are copied\n * from the incoming request VERBATIM (same as the original `proxy.ts`). On\n * Vercel the platform itself sets/overwrites these ahead of the Route\n * Handler, so they're trustworthy there — but this is a distributable SDK a\n * SITE can deploy anywhere. On a host where nothing strips inbound spoofed\n * values, a client can forge the IP base-api rate-limits on. Deploy this\n * factory only behind a platform that normalizes/overwrites these headers,\n * or in front of infra that strips client-supplied ones.\n */\nimport type { NextRequest } from 'next/server'\n\nexport interface CreateProxyRouteOptions {\n /** Overrides `REBLU_API_BASE_URL`. Read lazily per-request when omitted. */\n baseUrl?: string\n /** Overrides `REBLU_API_KEY`. Read lazily per-request when omitted. */\n apiKey?: string\n /** @default 'POST' */\n method?: string\n /** Forward upstream `Set-Cookie` headers to the browser. @default false */\n forwardSetCookie?: boolean\n /**\n * Reject non-`application/json` requests with a Spanish 400 before ever\n * reaching the network — mirrors `magic-link-exchange/route.ts`'s guard.\n * @default false\n */\n requireJson?: boolean\n /**\n * Overrides the Spanish `message` field of the `requireJson` 400 body, for\n * a non-Spanish-default SITE. The `error` code (`invalid_request`) and\n * shape stay fixed — only the human-facing text is overridable.\n * @default 'Solicitud no válida'\n */\n invalidRequestMessage?: string\n /**\n * Hard timeout (ms) for the upstream fetch, enforced via `AbortController`\n * — mirrors `@reblu/site-client`'s own `timeoutMs` (default `8000`, same\n * value). Without this, a hung base-api response would hang the SITE's\n * Route Handler indefinitely instead of degrading.\n * @default 8000\n */\n timeoutMs?: number\n /**\n * Overrides the `message` field of the 500 body returned when neither\n * `opts.baseUrl`/`opts.apiKey` nor `REBLU_API_BASE_URL`/`REBLU_API_KEY`\n * resolve to a non-empty value.\n * @default 'Error de configuración del servidor.'\n */\n configurationErrorMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch times out.\n * @default 'La solicitud ha superado el tiempo de espera.'\n */\n timeoutMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch fails for any other reason (DNS, connection refused,\n * etc.).\n * @default 'Error de conexión con el servidor.'\n */\n proxyErrorMessage?: string\n}\n\nfunction jsonErrorResponse(status: number, error: string, message: string): Response {\n return new Response(\n JSON.stringify({ error, message, correlation_id: crypto.randomUUID() }),\n { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } },\n )\n}\n\n/**\n * Creates a Next.js Route Handler that proxies `request` to\n * `${baseUrl}${targetPath}` at base-api, injecting the `X-Reblu-Api-Key`\n * server-side so it never reaches the browser.\n */\nexport function createProxyRoute(targetPath: string, opts: CreateProxyRouteOptions = {}) {\n const {\n method = 'POST',\n forwardSetCookie = false,\n requireJson = false,\n invalidRequestMessage = 'Solicitud no válida',\n timeoutMs = 8000,\n configurationErrorMessage = 'Error de configuración del servidor.',\n timeoutMessage = 'La solicitud ha superado el tiempo de espera.',\n proxyErrorMessage = 'Error de conexión con el servidor.',\n } = opts\n\n return async function handler(request: NextRequest): Promise<Response> {\n if (requireJson) {\n const contentType = request.headers.get('content-type') ?? ''\n if (!contentType.includes('application/json')) {\n return jsonErrorResponse(400, 'invalid_request', invalidRequestMessage)\n }\n }\n\n // Lazy, per-request env read (R6) — opts win when provided.\n const baseUrl = opts.baseUrl ?? process.env.REBLU_API_BASE_URL ?? ''\n const apiKey = opts.apiKey ?? process.env.REBLU_API_KEY ?? ''\n\n if (!baseUrl || !apiKey) {\n console.error(\n `[createProxyRoute] Missing configuration for ${targetPath}: ` +\n `${!baseUrl ? 'baseUrl/REBLU_API_BASE_URL ' : ''}${!apiKey ? 'apiKey/REBLU_API_KEY' : ''}`.trim(),\n )\n return jsonErrorResponse(500, 'missing_configuration', configurationErrorMessage)\n }\n\n const forwardedHeaders: Record<string, string> = {}\n const ua = request.headers.get('user-agent')\n if (ua) forwardedHeaders['user-agent'] = ua\n const xff = request.headers.get('x-forwarded-for')\n if (xff) forwardedHeaders['x-forwarded-for'] = xff\n const xri = request.headers.get('x-real-ip')\n if (xri) forwardedHeaders['x-real-ip'] = xri\n\n const upperMethod = method.toUpperCase()\n const body =\n upperMethod === 'GET' || upperMethod === 'HEAD' ? undefined : await request.text()\n // Forward the original query string (e.g. `?slug=x`) regardless of\n // method — `targetPath` is fixed at factory-call time, so without this a\n // GET route configured via `{ method: 'GET' }` would silently drop it.\n const search = request.nextUrl?.search ?? ''\n\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n try {\n const res = await fetch(`${baseUrl}${targetPath}${search}`, {\n method,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Reblu-Api-Key': apiKey,\n ...forwardedHeaders,\n },\n ...(body ? { body } : {}),\n cache: 'no-store',\n signal: controller.signal,\n })\n\n const data = await res.text()\n const responseHeaders = new Headers({\n 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',\n })\n\n if (forwardSetCookie) {\n for (const cookie of res.headers.getSetCookie()) {\n responseHeaders.append('set-cookie', cookie)\n }\n }\n\n return new Response(data, { status: res.status, headers: responseHeaders })\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n console.error(`[createProxyRoute] Timed out forwarding to ${targetPath} after ${timeoutMs}ms`)\n return jsonErrorResponse(502, 'upstream_timeout', timeoutMessage)\n }\n console.error(`[createProxyRoute] Error forwarding to ${targetPath}:`, err)\n return jsonErrorResponse(502, 'proxy_error', proxyErrorMessage)\n } finally {\n clearTimeout(timer)\n }\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/route-handlers.ts"],"sourcesContent":["/**\n * `@reblu/site-next/route-handlers` — server-only, dependency-free Route\n * Handler factory (REB-510 / design doc \"site-auth-package\").\n *\n * This module MUST stay dependency-free (no `@reblu/site-ui`, no React): it is\n * imported directly by `app/api/*` Route Handler files, and its own tsup entry\n * carries NO `'use client'` directive (mirrors the `cache` subpath's R4\n * precedent for the opposite direction — see `tsup.config.ts`).\n *\n * Scope: this factory covers the `/api/site/auth/*` proxy shape specifically\n * (fixed target path, JSON body passthrough, optional `Set-Cookie` forward) —\n * the same shape as every one of demo-site's 8 auth Route Handlers. It is\n * modeled on `apps/demo-site/src/lib/api/proxy.ts` (`proxyToBaseApi`) but is\n * NOT a drop-in replacement for every one of that helper's call sites: it\n * does not support a per-request DYNAMIC `targetPath` (e.g.\n * `api/shop/order/[sessionId]/route.ts`'s `` `/api/site/shop/order/${id}` ``)\n * or an arbitrary extra-`headers` passthrough — both are genuinely out of\n * scope for REB-510 (auth only). demo-site's non-auth proxy routes keep using\n * `proxyToBaseApi` directly; only the 8 auth routes are candidates to adopt\n * this factory.\n *\n * export const POST = createProxyRoute('/api/site/auth/login')\n *\n * Deliberate behavior change vs the original (R6): `proxy.ts` reads\n * `REBLU_API_BASE_URL`/`REBLU_API_KEY` at MODULE LOAD. This factory reads them\n * LAZILY, inside the returned handler, on every request — `opts.baseUrl` /\n * `opts.apiKey` still take priority when provided.\n *\n * Why this factory reads `process.env` at all, unlike `@reblu/site-client`\n * (whose own docblock states \"the SDK never reads `process.env` itself\"):\n * `site-client` is instantiated ONCE by the consuming SITE, which already\n * owns and injects its env at that single call site. `createProxyRoute` is\n * the opposite shape on purpose — a ZERO-CONFIG one-liner meant to be called\n * once per Route Handler FILE (8+ files in a typical SITE), so requiring\n * every file to re-thread `baseUrl`/`apiKey` would defeat the \"one line\"\n * value proposition documented in the README. The env fallback is this\n * factory's deliberate ergonomic default; `opts.baseUrl`/`opts.apiKey` remain\n * available for a SITE that wants explicit, non-`process.env` control.\n *\n * Trust note on header forwarding: `X-Forwarded-For`/`X-Real-IP` are copied\n * from the incoming request VERBATIM (same as the original `proxy.ts`). On\n * Vercel the platform itself sets/overwrites these ahead of the Route\n * Handler, so they're trustworthy there — but this is a distributable SDK a\n * SITE can deploy anywhere. On a host where nothing strips inbound spoofed\n * values, a client can forge the IP base-api rate-limits on. Deploy this\n * factory only behind a platform that normalizes/overwrites these headers,\n * or in front of infra that strips client-supplied ones.\n */\nimport type { NextRequest } from 'next/server'\n\nexport interface CreateProxyRouteOptions {\n /** Overrides `REBLU_API_BASE_URL`. Read lazily per-request when omitted. */\n baseUrl?: string\n /** Overrides `REBLU_API_KEY`. Read lazily per-request when omitted. */\n apiKey?: string\n /** @default 'POST' */\n method?: string\n /** Forward upstream `Set-Cookie` headers to the browser. @default false */\n forwardSetCookie?: boolean\n /**\n * Reject non-`application/json` requests with a Spanish 400 before ever\n * reaching the network — mirrors `magic-link-exchange/route.ts`'s guard.\n * @default false\n */\n requireJson?: boolean\n /**\n * Overrides the Spanish `message` field of the `requireJson` 400 body, for\n * a non-Spanish-default SITE. The `error` code (`invalid_request`) and\n * shape stay fixed — only the human-facing text is overridable.\n * @default 'Solicitud no válida'\n */\n invalidRequestMessage?: string\n /**\n * Hard timeout (ms) for the upstream fetch, enforced via `AbortController`\n * — mirrors `@reblu/site-client`'s own `timeoutMs` (default `8000`, same\n * value). Without this, a hung base-api response would hang the SITE's\n * Route Handler indefinitely instead of degrading.\n * @default 8000\n */\n timeoutMs?: number\n /**\n * Overrides the `message` field of the 500 body returned when neither\n * `opts.baseUrl`/`opts.apiKey` nor `REBLU_API_BASE_URL`/`REBLU_API_KEY`\n * resolve to a non-empty value.\n * @default 'Error de configuración del servidor.'\n */\n configurationErrorMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch times out.\n * @default 'La solicitud ha superado el tiempo de espera.'\n */\n timeoutMessage?: string\n /**\n * Overrides the `message` field of the 502 body returned when the\n * upstream fetch fails for any other reason (DNS, connection refused,\n * etc.).\n * @default 'Error de conexión con el servidor.'\n */\n proxyErrorMessage?: string\n /**\n * Transform the raw request body before it is forwarded upstream (REB-837\n * — `createShopCheckoutRoute`'s successPath/cancelPath default-injection\n * is the first caller). Receives the original `request` so a transform\n * can derive values from it (e.g. `request.nextUrl.origin`). Only called\n * for a non-GET/HEAD request with a non-empty body. A thrown error is\n * logged and the ORIGINAL body is forwarded unchanged — a transform bug\n * must never turn into a hard failure for every request through this\n * route.\n */\n transformBody?: (rawBody: string, request: NextRequest) => string\n}\n\nfunction jsonErrorResponse(status: number, error: string, message: string): Response {\n return new Response(\n JSON.stringify({ error, message, correlation_id: crypto.randomUUID() }),\n { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } },\n )\n}\n\n/**\n * Creates a Next.js Route Handler that proxies `request` to\n * `${baseUrl}${targetPath}` at base-api, injecting the `X-Reblu-Api-Key`\n * server-side so it never reaches the browser.\n */\nexport function createProxyRoute(targetPath: string, opts: CreateProxyRouteOptions = {}) {\n const {\n method = 'POST',\n forwardSetCookie = false,\n requireJson = false,\n invalidRequestMessage = 'Solicitud no válida',\n timeoutMs = 8000,\n configurationErrorMessage = 'Error de configuración del servidor.',\n timeoutMessage = 'La solicitud ha superado el tiempo de espera.',\n proxyErrorMessage = 'Error de conexión con el servidor.',\n } = opts\n\n return async function handler(request: NextRequest): Promise<Response> {\n if (requireJson) {\n const contentType = request.headers.get('content-type') ?? ''\n if (!contentType.includes('application/json')) {\n return jsonErrorResponse(400, 'invalid_request', invalidRequestMessage)\n }\n }\n\n // Lazy, per-request env read (R6) — opts win when provided.\n const baseUrl = opts.baseUrl ?? process.env.REBLU_API_BASE_URL ?? ''\n const apiKey = opts.apiKey ?? process.env.REBLU_API_KEY ?? ''\n\n if (!baseUrl || !apiKey) {\n console.error(\n `[createProxyRoute] Missing configuration for ${targetPath}: ` +\n `${!baseUrl ? 'baseUrl/REBLU_API_BASE_URL ' : ''}${!apiKey ? 'apiKey/REBLU_API_KEY' : ''}`.trim(),\n )\n return jsonErrorResponse(500, 'missing_configuration', configurationErrorMessage)\n }\n\n const forwardedHeaders: Record<string, string> = {}\n const ua = request.headers.get('user-agent')\n if (ua) forwardedHeaders['user-agent'] = ua\n const xff = request.headers.get('x-forwarded-for')\n if (xff) forwardedHeaders['x-forwarded-for'] = xff\n const xri = request.headers.get('x-real-ip')\n if (xri) forwardedHeaders['x-real-ip'] = xri\n\n const upperMethod = method.toUpperCase()\n let body =\n upperMethod === 'GET' || upperMethod === 'HEAD' ? undefined : await request.text()\n if (body && opts.transformBody) {\n try {\n body = opts.transformBody(body, request)\n } catch (err) {\n console.error(`[createProxyRoute] transformBody threw for ${targetPath}:`, err)\n }\n }\n // Forward the original query string (e.g. `?slug=x`) regardless of\n // method — `targetPath` is fixed at factory-call time, so without this a\n // GET route configured via `{ method: 'GET' }` would silently drop it.\n const search = request.nextUrl?.search ?? ''\n\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n try {\n const res = await fetch(`${baseUrl}${targetPath}${search}`, {\n method,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Reblu-Api-Key': apiKey,\n ...forwardedHeaders,\n },\n ...(body ? { body } : {}),\n cache: 'no-store',\n signal: controller.signal,\n })\n\n const data = await res.text()\n const responseHeaders = new Headers({\n 'Content-Type': res.headers.get('Content-Type') ?? 'application/json',\n })\n\n if (forwardSetCookie) {\n for (const cookie of res.headers.getSetCookie()) {\n responseHeaders.append('set-cookie', cookie)\n }\n }\n\n return new Response(data, { status: res.status, headers: responseHeaders })\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n console.error(`[createProxyRoute] Timed out forwarding to ${targetPath} after ${timeoutMs}ms`)\n return jsonErrorResponse(502, 'upstream_timeout', timeoutMessage)\n }\n console.error(`[createProxyRoute] Error forwarding to ${targetPath}:`, err)\n return jsonErrorResponse(502, 'proxy_error', proxyErrorMessage)\n } finally {\n clearTimeout(timer)\n }\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -99,6 +99,17 @@ interface CreateProxyRouteOptions {
|
|
|
99
99
|
* @default 'Error de conexión con el servidor.'
|
|
100
100
|
*/
|
|
101
101
|
proxyErrorMessage?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Transform the raw request body before it is forwarded upstream (REB-837
|
|
104
|
+
* — `createShopCheckoutRoute`'s successPath/cancelPath default-injection
|
|
105
|
+
* is the first caller). Receives the original `request` so a transform
|
|
106
|
+
* can derive values from it (e.g. `request.nextUrl.origin`). Only called
|
|
107
|
+
* for a non-GET/HEAD request with a non-empty body. A thrown error is
|
|
108
|
+
* logged and the ORIGINAL body is forwarded unchanged — a transform bug
|
|
109
|
+
* must never turn into a hard failure for every request through this
|
|
110
|
+
* route.
|
|
111
|
+
*/
|
|
112
|
+
transformBody?: (rawBody: string, request: NextRequest) => string;
|
|
102
113
|
}
|
|
103
114
|
/**
|
|
104
115
|
* Creates a Next.js Route Handler that proxies `request` to
|