@decocms/apps-vtex 7.2.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/package.json +67 -0
- package/src/README.md +6 -0
- package/src/__tests__/client-segment-cookie.test.ts +255 -0
- package/src/__tests__/client-set-cookie-forward.test.ts +257 -0
- package/src/actions/address.ts +250 -0
- package/src/actions/analytics/sendEvent.ts +86 -0
- package/src/actions/auth.ts +333 -0
- package/src/actions/checkout.ts +522 -0
- package/src/actions/index.ts +11 -0
- package/src/actions/masterData.ts +168 -0
- package/src/actions/misc.ts +188 -0
- package/src/actions/newsletter.ts +105 -0
- package/src/actions/orders.ts +34 -0
- package/src/actions/profile.ts +201 -0
- package/src/actions/session.ts +85 -0
- package/src/actions/trigger.ts +43 -0
- package/src/actions/wishlist.ts +114 -0
- package/src/client.ts +667 -0
- package/src/commerceLoaders.ts +261 -0
- package/src/hooks/__tests__/createUseCart.test.ts +184 -0
- package/src/hooks/__tests__/createUseUser.test.ts +48 -0
- package/src/hooks/__tests__/createUseWishlist.test.ts +87 -0
- package/src/hooks/createUseCart.ts +360 -0
- package/src/hooks/createUseUser.ts +153 -0
- package/src/hooks/createUseWishlist.ts +242 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/useAutocomplete.ts +83 -0
- package/src/hooks/useCart.ts +243 -0
- package/src/hooks/useUser.ts +78 -0
- package/src/hooks/useWishlist.ts +119 -0
- package/src/index.ts +17 -0
- package/src/invoke.ts +181 -0
- package/src/loaders/ProductDetailsPage.ts +1 -0
- package/src/loaders/ProductList.ts +1 -0
- package/src/loaders/ProductListingPage.ts +1 -0
- package/src/loaders/address.ts +115 -0
- package/src/loaders/autocomplete.ts +58 -0
- package/src/loaders/brands.ts +44 -0
- package/src/loaders/cart.ts +52 -0
- package/src/loaders/catalog.ts +163 -0
- package/src/loaders/collections.ts +55 -0
- package/src/loaders/index.ts +19 -0
- package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +20 -0
- package/src/loaders/intelligentSearch/productDetailsPage.ts +95 -0
- package/src/loaders/intelligentSearch/productList.ts +154 -0
- package/src/loaders/intelligentSearch/productListingPage.ts +506 -0
- package/src/loaders/intelligentSearch/suggestions.ts +46 -0
- package/src/loaders/legacy/productDetailsPage.ts +1 -0
- package/src/loaders/legacy/productList.ts +1 -0
- package/src/loaders/legacy/relatedProductsLoader.ts +81 -0
- package/src/loaders/legacy.ts +635 -0
- package/src/loaders/logistics.ts +111 -0
- package/src/loaders/minicart.ts +78 -0
- package/src/loaders/navbar.ts +26 -0
- package/src/loaders/orders.ts +92 -0
- package/src/loaders/pageType.ts +68 -0
- package/src/loaders/payment.ts +97 -0
- package/src/loaders/productListFull.ts +159 -0
- package/src/loaders/profile.ts +138 -0
- package/src/loaders/promotion.ts +30 -0
- package/src/loaders/search.ts +124 -0
- package/src/loaders/session.ts +83 -0
- package/src/loaders/user.ts +87 -0
- package/src/loaders/wishlist.ts +89 -0
- package/src/loaders/wishlistProducts.ts +69 -0
- package/src/loaders/workflow/products.ts +64 -0
- package/src/loaders/workflow.ts +316 -0
- package/src/logo.png +0 -0
- package/src/middleware.ts +240 -0
- package/src/mod.ts +152 -0
- package/src/registry.ts +9 -0
- package/src/types.ts +248 -0
- package/src/utils/__tests__/cookieSanitizer.test.ts +162 -0
- package/src/utils/__tests__/fetch.test.ts +80 -0
- package/src/utils/__tests__/fetchCache.test.ts +112 -0
- package/src/utils/__tests__/instrumentedFetch.test.ts +158 -0
- package/src/utils/__tests__/intelligentSearch.test.ts +88 -0
- package/src/utils/__tests__/minicart.test.ts +184 -0
- package/src/utils/__tests__/operationRouter.test.ts +227 -0
- package/src/utils/__tests__/resourceRange.test.ts +32 -0
- package/src/utils/__tests__/sitemap.test.ts +185 -0
- package/src/utils/__tests__/slugify.test.ts +31 -0
- package/src/utils/__tests__/transform.test.ts +698 -0
- package/src/utils/accountLoaders.ts +203 -0
- package/src/utils/authHelpers.ts +85 -0
- package/src/utils/batch.ts +18 -0
- package/src/utils/cookieSanitizer.ts +173 -0
- package/src/utils/cookies.ts +167 -0
- package/src/utils/enrichment.ts +560 -0
- package/src/utils/fetch.ts +107 -0
- package/src/utils/fetchCache.ts +222 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/instrumentedFetch.ts +78 -0
- package/src/utils/intelligentSearch.ts +96 -0
- package/src/utils/legacy.ts +139 -0
- package/src/utils/minicart.ts +139 -0
- package/src/utils/operationRouter.ts +139 -0
- package/src/utils/pickAndOmit.ts +25 -0
- package/src/utils/proxy.ts +435 -0
- package/src/utils/resourceRange.ts +10 -0
- package/src/utils/segment.ts +152 -0
- package/src/utils/similars.ts +37 -0
- package/src/utils/sitemap.ts +282 -0
- package/src/utils/slugCache.ts +28 -0
- package/src/utils/slugify.ts +13 -0
- package/src/utils/transform.ts +1509 -0
- package/src/utils/types.ts +1884 -0
- package/src/utils/vtexId.ts +138 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory for the legacy invoke-based `useWishlist` hook.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the deco-cx/apps signal-based wishlist API used by migrated
|
|
5
|
+
* Fresh sites: `wishlist.addItem(productId, productGroupId)`,
|
|
6
|
+
* `removeItem(productId)`, `getItem(productId): boolean`.
|
|
7
|
+
*
|
|
8
|
+
* It is intentionally separate from the canonical `useWishlist` in
|
|
9
|
+
* `vtex/hooks/useWishlist.ts`, which is built on TanStack Query and exposes
|
|
10
|
+
* `{ items, isInWishlist, toggle, add, remove }`. Both can coexist.
|
|
11
|
+
*
|
|
12
|
+
* ## VTEX wishlist arg conventions
|
|
13
|
+
*
|
|
14
|
+
* The legacy hook's `addItem(productId, productGroupId)` argument names
|
|
15
|
+
* are misleading because they were originally derived from analytics
|
|
16
|
+
* `item_id` / `item_group_id`:
|
|
17
|
+
*
|
|
18
|
+
* - `productId` arg → analytics `item_id` → VTEX `sku` field on the wishlist
|
|
19
|
+
* - `productGroupId` arg → analytics `item_group_id` → VTEX `productId`
|
|
20
|
+
*
|
|
21
|
+
* The factory swaps them on the wire so the canonical
|
|
22
|
+
* `vtex/actions/wishlist.addItem` gets the right shape.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* // src/hooks/useWishlist.ts
|
|
27
|
+
* import { createUseWishlist } from "@decocms/apps/vtex/hooks/createUseWishlist";
|
|
28
|
+
* import { invoke } from "~/server/invoke";
|
|
29
|
+
*
|
|
30
|
+
* export const { useWishlist, resetWishlist } = createUseWishlist({ invoke });
|
|
31
|
+
* export type { WishlistItem } from "@decocms/apps/vtex/loaders/wishlist";
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { useEffect, useState } from "react";
|
|
36
|
+
import type { WishlistItem } from "../loaders/wishlist";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pure helper: find a wishlist entry by either the SKU id (legacy
|
|
40
|
+
* `productId` arg) or the VTEX productId. Exported for unit testability.
|
|
41
|
+
*/
|
|
42
|
+
export function findWishlistEntry(
|
|
43
|
+
items: readonly WishlistItem[],
|
|
44
|
+
productId: string,
|
|
45
|
+
): WishlistItem | undefined {
|
|
46
|
+
return items.find((it) => it.sku === productId || it.productId === productId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pure helper: convert legacy `addItem(productId, productGroupId)` args
|
|
51
|
+
* into the canonical `{ productId, sku }` shape expected by
|
|
52
|
+
* `vtex/actions/wishlist.addItem`. Exported for unit testability.
|
|
53
|
+
*/
|
|
54
|
+
export function legacyAddArgsToCanonical(
|
|
55
|
+
legacyProductId: string,
|
|
56
|
+
legacyProductGroupId: string,
|
|
57
|
+
): { productId: string; sku: string } {
|
|
58
|
+
// See arg conventions in the file header. The legacy `productId` is
|
|
59
|
+
// the SKU; the legacy `productGroupId` is the VTEX productId.
|
|
60
|
+
return {
|
|
61
|
+
productId: legacyProductGroupId,
|
|
62
|
+
sku: legacyProductId,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Minimal structural shape of the invoke proxy this hook needs. */
|
|
67
|
+
export interface CreateUseWishlistInvoke {
|
|
68
|
+
vtex: {
|
|
69
|
+
loaders: {
|
|
70
|
+
wishlist: () => Promise<WishlistItem[]>;
|
|
71
|
+
};
|
|
72
|
+
actions: {
|
|
73
|
+
addToWishlist: (args: {
|
|
74
|
+
data: { productId: string; sku: string; title?: string };
|
|
75
|
+
}) => Promise<WishlistItem[]>;
|
|
76
|
+
removeFromWishlist: (args: { data: { id: string } }) => Promise<WishlistItem[]>;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CreateUseWishlistOptions {
|
|
82
|
+
invoke: CreateUseWishlistInvoke;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build a per-site `useWishlist` plus its companions. */
|
|
86
|
+
export function createUseWishlist(opts: CreateUseWishlistOptions) {
|
|
87
|
+
const { invoke } = opts;
|
|
88
|
+
|
|
89
|
+
let _items: WishlistItem[] = [];
|
|
90
|
+
let _loading = false;
|
|
91
|
+
let _initStarted = false;
|
|
92
|
+
let _initFailed = false;
|
|
93
|
+
const _listeners = new Set<() => void>();
|
|
94
|
+
|
|
95
|
+
function notify() {
|
|
96
|
+
for (const fn of _listeners) fn();
|
|
97
|
+
}
|
|
98
|
+
function setItems(items: WishlistItem[]) {
|
|
99
|
+
_items = items;
|
|
100
|
+
notify();
|
|
101
|
+
}
|
|
102
|
+
function setLoading(v: boolean) {
|
|
103
|
+
_loading = v;
|
|
104
|
+
notify();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getItem(productId: string): boolean {
|
|
108
|
+
return !!findWishlistEntry(_items, productId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function addItem(productId: string, productGroupId: string): Promise<void> {
|
|
112
|
+
setLoading(true);
|
|
113
|
+
try {
|
|
114
|
+
const updated = await invoke.vtex.actions.addToWishlist({
|
|
115
|
+
data: legacyAddArgsToCanonical(productId, productGroupId),
|
|
116
|
+
});
|
|
117
|
+
setItems(updated);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error("[useWishlist] addItem failed:", err);
|
|
120
|
+
throw err;
|
|
121
|
+
} finally {
|
|
122
|
+
setLoading(false);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function removeItem(productId: string): Promise<void> {
|
|
127
|
+
const entry = findWishlistEntry(_items, productId);
|
|
128
|
+
if (!entry?.id) {
|
|
129
|
+
// Either the wishlist hasn't loaded yet or the item isn't there.
|
|
130
|
+
// Either way, nothing to remove.
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
setLoading(true);
|
|
134
|
+
try {
|
|
135
|
+
const updated = await invoke.vtex.actions.removeFromWishlist({
|
|
136
|
+
data: { id: entry.id },
|
|
137
|
+
});
|
|
138
|
+
setItems(updated);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error("[useWishlist] removeItem failed:", err);
|
|
141
|
+
throw err;
|
|
142
|
+
} finally {
|
|
143
|
+
setLoading(false);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function refresh(): Promise<WishlistItem[]> {
|
|
148
|
+
setLoading(true);
|
|
149
|
+
try {
|
|
150
|
+
const items = await invoke.vtex.loaders.wishlist();
|
|
151
|
+
setItems(items);
|
|
152
|
+
_initFailed = false;
|
|
153
|
+
return items;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error("[useWishlist] refresh failed:", err);
|
|
156
|
+
_initFailed = true;
|
|
157
|
+
notify();
|
|
158
|
+
return [];
|
|
159
|
+
} finally {
|
|
160
|
+
setLoading(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Reset module-level wishlist state so the next useWishlist() re-fetches. */
|
|
165
|
+
function resetWishlist() {
|
|
166
|
+
_items = [];
|
|
167
|
+
_loading = false;
|
|
168
|
+
_initStarted = false;
|
|
169
|
+
_initFailed = false;
|
|
170
|
+
notify();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function useWishlist() {
|
|
174
|
+
const [, forceRender] = useState(0);
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
const listener = () => forceRender((n) => n + 1);
|
|
178
|
+
_listeners.add(listener);
|
|
179
|
+
|
|
180
|
+
if (_items.length === 0 && !_initStarted) {
|
|
181
|
+
_initStarted = true;
|
|
182
|
+
setLoading(true);
|
|
183
|
+
invoke.vtex.loaders
|
|
184
|
+
.wishlist()
|
|
185
|
+
.then((items) => {
|
|
186
|
+
setItems(items);
|
|
187
|
+
})
|
|
188
|
+
.catch((err: unknown) => {
|
|
189
|
+
// 401 / unauthenticated is normal — user just isn't logged in.
|
|
190
|
+
// Real errors get logged.
|
|
191
|
+
console.error("[useWishlist] init failed:", err);
|
|
192
|
+
_initFailed = true;
|
|
193
|
+
notify();
|
|
194
|
+
})
|
|
195
|
+
.finally(() => setLoading(false));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return () => {
|
|
199
|
+
_listeners.delete(listener);
|
|
200
|
+
};
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
items: {
|
|
205
|
+
get value() {
|
|
206
|
+
return _items;
|
|
207
|
+
},
|
|
208
|
+
set value(v: WishlistItem[]) {
|
|
209
|
+
setItems(v);
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
loading: {
|
|
214
|
+
get value() {
|
|
215
|
+
return _loading;
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
initFailed: {
|
|
220
|
+
get value() {
|
|
221
|
+
return _initFailed;
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
count: {
|
|
226
|
+
get value() {
|
|
227
|
+
return _items.length;
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
addItem,
|
|
232
|
+
removeItem,
|
|
233
|
+
getItem,
|
|
234
|
+
refresh,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
useWishlist,
|
|
240
|
+
resetWishlist,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type CreateUseCartInvoke,
|
|
3
|
+
type CreateUseCartOptions,
|
|
4
|
+
createUseCart,
|
|
5
|
+
} from "./createUseCart";
|
|
6
|
+
export {
|
|
7
|
+
type CreateUseUserInvoke,
|
|
8
|
+
type CreateUseUserOptions,
|
|
9
|
+
createUseUser,
|
|
10
|
+
} from "./createUseUser";
|
|
11
|
+
export {
|
|
12
|
+
type CreateUseWishlistInvoke,
|
|
13
|
+
type CreateUseWishlistOptions,
|
|
14
|
+
createUseWishlist,
|
|
15
|
+
} from "./createUseWishlist";
|
|
16
|
+
export { type UseAutocompleteOptions, useAutocomplete } from "./useAutocomplete";
|
|
17
|
+
export { type CartItem, type OrderForm, type UseCartOptions, useCart } from "./useCart";
|
|
18
|
+
export { type UseUserOptions, useUser, type VtexUser } from "./useUser";
|
|
19
|
+
export { type UseWishlistOptions, useWishlist, type WishlistItem } from "./useWishlist";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side autocomplete hook for VTEX Intelligent Search.
|
|
3
|
+
*
|
|
4
|
+
* Uses TanStack Query with debounced input to fetch search suggestions.
|
|
5
|
+
* Ported from deco-cx/apps vtex/hooks/useAutocomplete.ts
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { useAutocomplete } from "@decocms/apps/vtex/hooks/useAutocomplete";
|
|
10
|
+
*
|
|
11
|
+
* function SearchBar() {
|
|
12
|
+
* const { setSearch, suggestions, loading } = useAutocomplete();
|
|
13
|
+
* return (
|
|
14
|
+
* <input
|
|
15
|
+
* onChange={(e) => setSearch(e.target.value)}
|
|
16
|
+
* placeholder="Search..."
|
|
17
|
+
* />
|
|
18
|
+
* );
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useQuery } from "@tanstack/react-query";
|
|
24
|
+
import { useCallback, useEffect, useState } from "react";
|
|
25
|
+
import type { Suggestion } from "@decocms/apps-commerce/types";
|
|
26
|
+
|
|
27
|
+
export interface UseAutocompleteOptions {
|
|
28
|
+
/** Debounce delay in ms @default 250 */
|
|
29
|
+
debounceMs?: number;
|
|
30
|
+
/** Max products to return @default 4 */
|
|
31
|
+
count?: number;
|
|
32
|
+
/** Custom fetch function — defaults to calling the inline-loader on the server */
|
|
33
|
+
fetchSuggestions?: (query: string, count: number) => Promise<Suggestion | null>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const AUTOCOMPLETE_QUERY_KEY = "vtex-autocomplete";
|
|
37
|
+
|
|
38
|
+
function useDebounce<T>(value: T, delay: number): T {
|
|
39
|
+
const [debounced, setDebounced] = useState(value);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const timer = setTimeout(() => setDebounced(value), delay);
|
|
42
|
+
return () => clearTimeout(timer);
|
|
43
|
+
}, [value, delay]);
|
|
44
|
+
return debounced;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function defaultFetchSuggestions(query: string, _count: number): Promise<Suggestion | null> {
|
|
48
|
+
const params = new URLSearchParams({ query });
|
|
49
|
+
const res = await fetch(`/api/vtex/suggestions?${params}`);
|
|
50
|
+
if (!res.ok) return null;
|
|
51
|
+
return res.json();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useAutocomplete(opts?: UseAutocompleteOptions) {
|
|
55
|
+
const debounceMs = opts?.debounceMs ?? 250;
|
|
56
|
+
const count = opts?.count ?? 4;
|
|
57
|
+
const fetchFn = opts?.fetchSuggestions ?? defaultFetchSuggestions;
|
|
58
|
+
|
|
59
|
+
const [rawQuery, setRawQuery] = useState("");
|
|
60
|
+
const debouncedQuery = useDebounce(rawQuery.trim(), debounceMs);
|
|
61
|
+
|
|
62
|
+
const { data, isLoading, isFetching } = useQuery({
|
|
63
|
+
queryKey: [AUTOCOMPLETE_QUERY_KEY, debouncedQuery, count],
|
|
64
|
+
queryFn: () => fetchFn(debouncedQuery, count),
|
|
65
|
+
enabled: debouncedQuery.length > 0,
|
|
66
|
+
staleTime: 60_000,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const setSearch = useCallback((query: string) => {
|
|
70
|
+
setRawQuery(query);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
/** Set the search query (will be debounced automatically) */
|
|
75
|
+
setSearch,
|
|
76
|
+
/** Current raw (un-debounced) query */
|
|
77
|
+
query: rawQuery,
|
|
78
|
+
/** Suggestion result (searches + products) */
|
|
79
|
+
suggestions: data ?? null,
|
|
80
|
+
/** True while initial fetch is in progress */
|
|
81
|
+
loading: isLoading || isFetching,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side cart hook for VTEX.
|
|
3
|
+
*
|
|
4
|
+
* Uses TanStack Query for SWR, optimistic updates, and cache invalidation.
|
|
5
|
+
* Returns BOTH the raw `OrderForm` (back-compat for existing consumers) AND
|
|
6
|
+
* the canonical `Minicart` shape (preferred for new code).
|
|
7
|
+
*
|
|
8
|
+
* @example Reading the cart
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { useCart } from "@decocms/apps/vtex/hooks/useCart";
|
|
11
|
+
*
|
|
12
|
+
* function CartButton() {
|
|
13
|
+
* const { minicart, isLoading } = useCart({ freeShippingTarget: 200 });
|
|
14
|
+
* const count = minicart?.storefront.items.length ?? 0;
|
|
15
|
+
* return <button disabled={isLoading}>{count} items</button>;
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @example Mutations
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const { addItems, removeItem, addCoupons } = useCart();
|
|
22
|
+
* addItems.mutate([{ id: "123", seller: "1", quantity: 1 }]);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
27
|
+
import { useMemo } from "react";
|
|
28
|
+
import type { Minicart } from "@decocms/apps-commerce/types";
|
|
29
|
+
import type { OrderForm, OrderFormItem } from "../types";
|
|
30
|
+
import { vtexOrderFormToMinicart } from "../utils/minicart";
|
|
31
|
+
|
|
32
|
+
/** Re-exported from `vtex/types` for back-compat. New code should import directly. */
|
|
33
|
+
export type { OrderForm } from "../types";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Slim cart-item shape used by mutations.
|
|
37
|
+
* @deprecated Use `OrderFormItem` from `@decocms/apps/vtex/types` for full fidelity,
|
|
38
|
+
* or `MinicartItem` from `@decocms/apps/commerce/types` for the canonical contract.
|
|
39
|
+
*/
|
|
40
|
+
export type CartItem = Pick<OrderFormItem, "id" | "quantity" | "seller">;
|
|
41
|
+
|
|
42
|
+
const CART_QUERY_KEY = ["vtex", "cart"] as const;
|
|
43
|
+
|
|
44
|
+
const DEFAULT_EXPECTED_SECTIONS = [
|
|
45
|
+
"items",
|
|
46
|
+
"totalizers",
|
|
47
|
+
"clientProfileData",
|
|
48
|
+
"shippingData",
|
|
49
|
+
"paymentData",
|
|
50
|
+
"sellers",
|
|
51
|
+
"messages",
|
|
52
|
+
"marketingData",
|
|
53
|
+
"clientPreferencesData",
|
|
54
|
+
"storePreferencesData",
|
|
55
|
+
"giftRegistryData",
|
|
56
|
+
"ratesAndBenefitsData",
|
|
57
|
+
"openTextField",
|
|
58
|
+
"commercialConditionData",
|
|
59
|
+
"customData",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function getScParam(): string {
|
|
63
|
+
if (typeof window !== "undefined") {
|
|
64
|
+
const match = document.cookie.match(/(?:^|;\s*)VTEXSC=([^;]+)/);
|
|
65
|
+
return match?.[1] ?? "";
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function appendSc(url: string): string {
|
|
71
|
+
const sc = getScParam();
|
|
72
|
+
if (!sc) return url;
|
|
73
|
+
return url.includes("?") ? `${url}&sc=${sc}` : `${url}?sc=${sc}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function fetchCart(): Promise<OrderForm> {
|
|
77
|
+
const res = await fetch(appendSc("/api/checkout/pub/orderForm"), {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "Content-Type": "application/json" },
|
|
80
|
+
body: JSON.stringify({ expectedOrderFormSections: DEFAULT_EXPECTED_SECTIONS }),
|
|
81
|
+
credentials: "include",
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) throw new Error(`Cart fetch failed: ${res.status}`);
|
|
84
|
+
return res.json();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function addItemsToCart(
|
|
88
|
+
orderFormId: string,
|
|
89
|
+
items: Array<{ id: string; quantity: number; seller: string }>,
|
|
90
|
+
): Promise<OrderForm> {
|
|
91
|
+
const params = new URLSearchParams();
|
|
92
|
+
params.append("allowedOutdatedData", "paymentData");
|
|
93
|
+
const sc = getScParam();
|
|
94
|
+
if (sc) params.set("sc", sc);
|
|
95
|
+
|
|
96
|
+
const res = await fetch(`/api/checkout/pub/orderForm/${orderFormId}/items?${params}`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ orderItems: items }),
|
|
100
|
+
credentials: "include",
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) throw new Error(`Add to cart failed: ${res.status}`);
|
|
103
|
+
return res.json();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function addCouponsToCart(orderFormId: string, text: string): Promise<OrderForm> {
|
|
107
|
+
const params = new URLSearchParams();
|
|
108
|
+
const sc = getScParam();
|
|
109
|
+
if (sc) params.set("sc", sc);
|
|
110
|
+
|
|
111
|
+
const res = await fetch(`/api/checkout/pub/orderForm/${orderFormId}/coupons?${params}`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify({ text }),
|
|
115
|
+
credentials: "include",
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) throw new Error(`Add coupon failed: ${res.status}`);
|
|
118
|
+
return res.json();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function updateItemQuantity(
|
|
122
|
+
orderFormId: string,
|
|
123
|
+
index: number,
|
|
124
|
+
quantity: number,
|
|
125
|
+
): Promise<OrderForm> {
|
|
126
|
+
const params = new URLSearchParams();
|
|
127
|
+
params.append("allowedOutdatedData", "paymentData");
|
|
128
|
+
const sc = getScParam();
|
|
129
|
+
if (sc) params.set("sc", sc);
|
|
130
|
+
|
|
131
|
+
const res = await fetch(`/api/checkout/pub/orderForm/${orderFormId}/items/update?${params}`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "Content-Type": "application/json" },
|
|
134
|
+
body: JSON.stringify({ orderItems: [{ index, quantity }] }),
|
|
135
|
+
credentials: "include",
|
|
136
|
+
});
|
|
137
|
+
if (!res.ok) throw new Error(`Update quantity failed: ${res.status}`);
|
|
138
|
+
return res.json();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface UseCartOptions {
|
|
142
|
+
/** Enable automatic refetching. @default true */
|
|
143
|
+
enabled?: boolean;
|
|
144
|
+
/** Stale time in ms. @default 30000 */
|
|
145
|
+
staleTime?: number;
|
|
146
|
+
/** Free-shipping threshold in major units, surfaced on `minicart.storefront`. @default 0 */
|
|
147
|
+
freeShippingTarget?: number;
|
|
148
|
+
/** Override the OrderForm's locale (BCP-47, e.g. `"pt-BR"`). */
|
|
149
|
+
locale?: string;
|
|
150
|
+
/** Where the checkout button sends the user. @default "/checkout" */
|
|
151
|
+
checkoutHref?: string;
|
|
152
|
+
/** Whether to surface the coupon input. @default true */
|
|
153
|
+
enableCoupon?: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function useCart(options?: UseCartOptions) {
|
|
157
|
+
const queryClient = useQueryClient();
|
|
158
|
+
|
|
159
|
+
const query = useQuery({
|
|
160
|
+
queryKey: CART_QUERY_KEY,
|
|
161
|
+
queryFn: fetchCart,
|
|
162
|
+
staleTime: options?.staleTime ?? 30_000,
|
|
163
|
+
enabled: options?.enabled !== false,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const cart = query.data ?? null;
|
|
167
|
+
|
|
168
|
+
const minicart: Minicart<OrderForm> | null = useMemo(() => {
|
|
169
|
+
if (!cart) return null;
|
|
170
|
+
return vtexOrderFormToMinicart(cart, {
|
|
171
|
+
freeShippingTarget: options?.freeShippingTarget,
|
|
172
|
+
locale: options?.locale,
|
|
173
|
+
checkoutHref: options?.checkoutHref,
|
|
174
|
+
enableCoupon: options?.enableCoupon,
|
|
175
|
+
});
|
|
176
|
+
}, [
|
|
177
|
+
cart,
|
|
178
|
+
options?.freeShippingTarget,
|
|
179
|
+
options?.locale,
|
|
180
|
+
options?.checkoutHref,
|
|
181
|
+
options?.enableCoupon,
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
const addItems = useMutation({
|
|
185
|
+
mutationFn: (items: Array<{ id: string; quantity: number; seller: string }>) => {
|
|
186
|
+
const orderFormId = query.data?.orderFormId;
|
|
187
|
+
if (!orderFormId) throw new Error("Cart not loaded");
|
|
188
|
+
return addItemsToCart(orderFormId, items);
|
|
189
|
+
},
|
|
190
|
+
onSuccess: (data) => {
|
|
191
|
+
queryClient.setQueryData(CART_QUERY_KEY, data);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const updateQuantity = useMutation({
|
|
196
|
+
mutationFn: ({ index, quantity }: { index: number; quantity: number }) => {
|
|
197
|
+
const orderFormId = query.data?.orderFormId;
|
|
198
|
+
if (!orderFormId) throw new Error("Cart not loaded");
|
|
199
|
+
return updateItemQuantity(orderFormId, index, quantity);
|
|
200
|
+
},
|
|
201
|
+
onSuccess: (data) => {
|
|
202
|
+
queryClient.setQueryData(CART_QUERY_KEY, data);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const removeItem = useMutation({
|
|
207
|
+
mutationFn: (index: number) => {
|
|
208
|
+
const orderFormId = query.data?.orderFormId;
|
|
209
|
+
if (!orderFormId) throw new Error("Cart not loaded");
|
|
210
|
+
return updateItemQuantity(orderFormId, index, 0);
|
|
211
|
+
},
|
|
212
|
+
onSuccess: (data) => {
|
|
213
|
+
queryClient.setQueryData(CART_QUERY_KEY, data);
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const addCoupons = useMutation({
|
|
218
|
+
mutationFn: (text: string) => {
|
|
219
|
+
const orderFormId = query.data?.orderFormId;
|
|
220
|
+
if (!orderFormId) throw new Error("Cart not loaded");
|
|
221
|
+
return addCouponsToCart(orderFormId, text);
|
|
222
|
+
},
|
|
223
|
+
onSuccess: (data) => {
|
|
224
|
+
queryClient.setQueryData(CART_QUERY_KEY, data);
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
/** Raw VTEX OrderForm — escape hatch for platform-specific reads. */
|
|
230
|
+
cart,
|
|
231
|
+
/** Canonical platform-agnostic minicart. Prefer this in new UI code. */
|
|
232
|
+
minicart,
|
|
233
|
+
isLoading: query.isLoading,
|
|
234
|
+
isError: query.isError,
|
|
235
|
+
error: query.error,
|
|
236
|
+
refetch: query.refetch,
|
|
237
|
+
addItems,
|
|
238
|
+
addCoupons,
|
|
239
|
+
updateQuantity,
|
|
240
|
+
removeItem,
|
|
241
|
+
itemCount: cart?.items?.length ?? 0,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side user/session hook for VTEX.
|
|
3
|
+
*
|
|
4
|
+
* Detects login state via the VTEX Sessions API (server-side accessible).
|
|
5
|
+
* Does NOT attempt to read VtexIdclientAutCookie client-side — that cookie
|
|
6
|
+
* is HttpOnly and inaccessible via document.cookie.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { useUser } from "@decocms/apps/vtex/hooks/useUser";
|
|
11
|
+
*
|
|
12
|
+
* function UserGreeting() {
|
|
13
|
+
* const { user, isLoggedIn } = useUser();
|
|
14
|
+
* if (!isLoggedIn) return <a href="/account">Sign In</a>;
|
|
15
|
+
* return <span>Hello, {user?.email}</span>;
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useQuery } from "@tanstack/react-query";
|
|
21
|
+
|
|
22
|
+
export interface VtexUser {
|
|
23
|
+
email?: string;
|
|
24
|
+
firstName?: string;
|
|
25
|
+
lastName?: string;
|
|
26
|
+
userId?: string;
|
|
27
|
+
isLoggedIn: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const USER_QUERY_KEY = ["vtex", "user"] as const;
|
|
31
|
+
|
|
32
|
+
async function fetchUser(): Promise<VtexUser> {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(
|
|
35
|
+
"/api/sessions?items=profile.email,profile.firstName,profile.lastName,profile.id",
|
|
36
|
+
{ credentials: "include" },
|
|
37
|
+
);
|
|
38
|
+
if (!res.ok) return { isLoggedIn: false };
|
|
39
|
+
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
const profile = data?.namespaces?.profile;
|
|
42
|
+
|
|
43
|
+
const email = profile?.email?.value;
|
|
44
|
+
if (!email) return { isLoggedIn: false };
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
email,
|
|
48
|
+
firstName: profile?.firstName?.value,
|
|
49
|
+
lastName: profile?.lastName?.value,
|
|
50
|
+
userId: profile?.id?.value,
|
|
51
|
+
isLoggedIn: true,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return { isLoggedIn: false };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface UseUserOptions {
|
|
59
|
+
enabled?: boolean;
|
|
60
|
+
staleTime?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useUser(options?: UseUserOptions) {
|
|
64
|
+
const query = useQuery({
|
|
65
|
+
queryKey: USER_QUERY_KEY,
|
|
66
|
+
queryFn: fetchUser,
|
|
67
|
+
staleTime: options?.staleTime ?? 30_000,
|
|
68
|
+
enabled: options?.enabled !== false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
user: query.data ?? null,
|
|
73
|
+
isLoggedIn: query.data?.isLoggedIn ?? false,
|
|
74
|
+
isLoading: query.isLoading,
|
|
75
|
+
isError: query.isError,
|
|
76
|
+
refetch: query.refetch,
|
|
77
|
+
};
|
|
78
|
+
}
|