@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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard VTEX commerce loader map factory for CMS block resolution.
|
|
3
|
+
*
|
|
4
|
+
* Wraps all VTEX loaders with createCachedLoader, applies universal
|
|
5
|
+
* workarounds (slug fallback, IS sort sanitization, map=productClusterIds),
|
|
6
|
+
* and registers both `.ts` and `.ts`-less aliases.
|
|
7
|
+
*
|
|
8
|
+
* Sites call `createVtexCommerceLoaders()` instead of manually wiring ~30
|
|
9
|
+
* individual loader entries. Site-specific loaders are merged via `extra`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createCachedLoader } from "@decocms/blocks/sdk/cachedLoader";
|
|
13
|
+
import type { CacheProfileName } from "@decocms/blocks/sdk/cacheHeaders";
|
|
14
|
+
import { getCategoryTree } from "./loaders/catalog";
|
|
15
|
+
import vtexProductDetailsPage from "./loaders/intelligentSearch/productDetailsPage";
|
|
16
|
+
import vtexProductListShelf from "./loaders/intelligentSearch/productList";
|
|
17
|
+
import vtexProductListingPage from "./loaders/intelligentSearch/productListingPage";
|
|
18
|
+
import vtexSuggestions from "./loaders/intelligentSearch/suggestions";
|
|
19
|
+
import vtexRelatedProducts from "./loaders/legacy/relatedProductsLoader";
|
|
20
|
+
import vtexProductList from "./loaders/productListFull";
|
|
21
|
+
import vtexWorkflowProducts from "./loaders/workflow/products";
|
|
22
|
+
import { VALID_IS_SORTS } from "./utils/intelligentSearch";
|
|
23
|
+
|
|
24
|
+
export type CommerceLoaderFn = (props: any) => Promise<any>;
|
|
25
|
+
|
|
26
|
+
export interface VtexCommerceLoadersOptions {
|
|
27
|
+
/** Override cache profiles per loader type. */
|
|
28
|
+
cacheProfiles?: {
|
|
29
|
+
listing?: CacheProfileName;
|
|
30
|
+
product?: CacheProfileName;
|
|
31
|
+
search?: CacheProfileName;
|
|
32
|
+
static?: CacheProfileName;
|
|
33
|
+
};
|
|
34
|
+
/** Additional loaders to merge into the map (site-specific). */
|
|
35
|
+
extra?: Record<string, CommerceLoaderFn>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bridge __pagePath → slug when CMS doesn't set slug explicitly.
|
|
40
|
+
* VTEX PDP pages receive __pagePath (e.g. "/produto-slug/p") but the
|
|
41
|
+
* inline loader only reads the `slug` field.
|
|
42
|
+
*/
|
|
43
|
+
function pdpWithSlugFallback(props: any): Promise<any> {
|
|
44
|
+
if ((!props.slug || props.slug.length === 0) && props.__pagePath) {
|
|
45
|
+
props = { ...props, slug: props.__pagePath };
|
|
46
|
+
}
|
|
47
|
+
return vtexProductDetailsPage(props);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract collection name from PLP product data.
|
|
52
|
+
* Products carry cluster info in additionalProperty with name="cluster".
|
|
53
|
+
*/
|
|
54
|
+
function extractCollectionName(result: any, collectionId: string): string | null {
|
|
55
|
+
if (!result?.products?.length) return null;
|
|
56
|
+
for (const product of result.products) {
|
|
57
|
+
const props = product.additionalProperty || product.isVariantOf?.additionalProperty || [];
|
|
58
|
+
for (const prop of props) {
|
|
59
|
+
if (prop.name === "cluster" && prop.propertyID === collectionId) {
|
|
60
|
+
return prop.value || null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns the standard VTEX commerce loader map for CMS resolution.
|
|
69
|
+
*
|
|
70
|
+
* Includes all Intelligent Search, legacy, category tree, and navbar loaders
|
|
71
|
+
* with SWR caching. Also registers `.ts`-less aliases for invoke compatibility.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* import { createVtexCommerceLoaders } from "@decocms/apps/vtex/commerceLoaders";
|
|
76
|
+
*
|
|
77
|
+
* const COMMERCE_LOADERS = {
|
|
78
|
+
* ...createVtexCommerceLoaders(),
|
|
79
|
+
* // site-specific only:
|
|
80
|
+
* "site/loaders/myCustomLoader": async (props) => { ... },
|
|
81
|
+
* };
|
|
82
|
+
* registerCommerceLoaders(COMMERCE_LOADERS);
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function createVtexCommerceLoaders(
|
|
86
|
+
options?: VtexCommerceLoadersOptions,
|
|
87
|
+
): Record<string, CommerceLoaderFn> {
|
|
88
|
+
const profiles = {
|
|
89
|
+
listing: options?.cacheProfiles?.listing ?? "listing",
|
|
90
|
+
product: options?.cacheProfiles?.product ?? "product",
|
|
91
|
+
search: options?.cacheProfiles?.search ?? "search",
|
|
92
|
+
static: options?.cacheProfiles?.static ?? "static",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const _cachedProductList = createCachedLoader(
|
|
96
|
+
"vtex/productList",
|
|
97
|
+
vtexProductList,
|
|
98
|
+
profiles.listing,
|
|
99
|
+
);
|
|
100
|
+
const cachedProductListShelf = createCachedLoader(
|
|
101
|
+
"vtex/productListShelf",
|
|
102
|
+
vtexProductListShelf,
|
|
103
|
+
profiles.listing,
|
|
104
|
+
);
|
|
105
|
+
const cachedPDP = createCachedLoader(
|
|
106
|
+
"vtex/productDetailsPage",
|
|
107
|
+
pdpWithSlugFallback,
|
|
108
|
+
profiles.product,
|
|
109
|
+
);
|
|
110
|
+
const _cachedPLP = createCachedLoader(
|
|
111
|
+
"vtex/productListingPage",
|
|
112
|
+
vtexProductListingPage,
|
|
113
|
+
profiles.listing,
|
|
114
|
+
);
|
|
115
|
+
const cachedSuggestions = createCachedLoader(
|
|
116
|
+
"vtex/suggestions",
|
|
117
|
+
vtexSuggestions,
|
|
118
|
+
profiles.search,
|
|
119
|
+
);
|
|
120
|
+
const cachedRelatedProducts = createCachedLoader(
|
|
121
|
+
"vtex/relatedProducts",
|
|
122
|
+
vtexRelatedProducts,
|
|
123
|
+
profiles.product,
|
|
124
|
+
);
|
|
125
|
+
const cachedWorkflowProducts = createCachedLoader(
|
|
126
|
+
"vtex/workflowProducts",
|
|
127
|
+
vtexWorkflowProducts,
|
|
128
|
+
profiles.listing,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* PLP wrapper: handles `map=productClusterIds` legacy URLs and sanitizes
|
|
133
|
+
* IS sort parameters that would cause VTEX API 400 errors.
|
|
134
|
+
*/
|
|
135
|
+
const cachedPLP: CommerceLoaderFn = async (props) => {
|
|
136
|
+
if (props.__pageUrl && !props.selectedFacets?.length) {
|
|
137
|
+
try {
|
|
138
|
+
const pageUrl = new URL(props.__pageUrl, "https://localhost");
|
|
139
|
+
const mapParam = pageUrl.searchParams.get("map");
|
|
140
|
+
if (mapParam && props.__pagePath) {
|
|
141
|
+
const segments = props.__pagePath.split("/").filter(Boolean);
|
|
142
|
+
const mapValues = mapParam.split(",");
|
|
143
|
+
const facets: Array<{ key: string; value: string }> = [];
|
|
144
|
+
for (let i = 0; i < Math.min(segments.length, mapValues.length); i++) {
|
|
145
|
+
const key = mapValues[i].trim();
|
|
146
|
+
const value = decodeURIComponent(segments[i]);
|
|
147
|
+
if (key && value) facets.push({ key, value });
|
|
148
|
+
}
|
|
149
|
+
if (facets.length) {
|
|
150
|
+
const rawSort = pageUrl.searchParams.get("sort") ?? "";
|
|
151
|
+
const cleanSort = VALID_IS_SORTS.has(rawSort) ? rawSort : "";
|
|
152
|
+
|
|
153
|
+
if (rawSort !== cleanSort) {
|
|
154
|
+
if (cleanSort) {
|
|
155
|
+
pageUrl.searchParams.set("sort", cleanSort);
|
|
156
|
+
} else {
|
|
157
|
+
pageUrl.searchParams.delete("sort");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const result = await _cachedPLP({
|
|
162
|
+
...props,
|
|
163
|
+
selectedFacets: facets,
|
|
164
|
+
sort: cleanSort || undefined,
|
|
165
|
+
__pageUrl: pageUrl.toString(),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const clusterFacet = facets.find((f) => f.key === "productClusterIds");
|
|
169
|
+
if (result && clusterFacet) {
|
|
170
|
+
const collectionName = extractCollectionName(result, clusterFacet.value);
|
|
171
|
+
if (collectionName) {
|
|
172
|
+
result.breadcrumb = {
|
|
173
|
+
"@type": "BreadcrumbList",
|
|
174
|
+
itemListElement: [
|
|
175
|
+
{
|
|
176
|
+
"@type": "ListItem",
|
|
177
|
+
name: collectionName,
|
|
178
|
+
item: props.__pagePath || "/",
|
|
179
|
+
position: 1,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
numberOfItems: 1,
|
|
183
|
+
};
|
|
184
|
+
result.seo = { ...result.seo, title: collectionName };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.error("[PLP] Error parsing map param:", e);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return _cachedPLP(props);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Related products wrapper: extracts slug from __pagePath when the CMS
|
|
199
|
+
* requestToParam stub returns null (standard in TanStack sites).
|
|
200
|
+
*/
|
|
201
|
+
const relatedWithSlugFallback: CommerceLoaderFn = (props) => {
|
|
202
|
+
if (!props.slug && props.__pagePath) {
|
|
203
|
+
const path = props.__pagePath.replace(/\/p$/, "").replace(/^\//, "");
|
|
204
|
+
props = { ...props, slug: path };
|
|
205
|
+
}
|
|
206
|
+
return cachedRelatedProducts(props);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const loaders: Record<string, CommerceLoaderFn> = {
|
|
210
|
+
// Intelligent Search loaders
|
|
211
|
+
"vtex/loaders/intelligentSearch/productListingPage.ts": cachedPLP,
|
|
212
|
+
"vtex/loaders/intelligentSearch/productList.ts": cachedProductListShelf,
|
|
213
|
+
"vtex/loaders/intelligentSearch/productDetailsPage.ts": cachedPDP,
|
|
214
|
+
"vtex/loaders/intelligentSearch/suggestions.ts": cachedSuggestions,
|
|
215
|
+
// Legacy loaders (map to same cached functions)
|
|
216
|
+
"vtex/loaders/legacy/productDetailsPage.ts": cachedPDP,
|
|
217
|
+
"vtex/loaders/legacy/productList.ts": cachedProductListShelf,
|
|
218
|
+
"vtex/loaders/legacy/relatedProductsLoader.ts": relatedWithSlugFallback,
|
|
219
|
+
// Workflow
|
|
220
|
+
"vtex/loaders/workflow/products.ts": cachedWorkflowProducts,
|
|
221
|
+
// Top-level aliases (used by some CMS block configs)
|
|
222
|
+
"vtex/loaders/ProductList.ts": cachedProductListShelf,
|
|
223
|
+
"vtex/loaders/ProductDetailsPage.ts": cachedPDP,
|
|
224
|
+
"vtex/loaders/ProductListingPage.ts": cachedPLP,
|
|
225
|
+
// Category tree
|
|
226
|
+
"vtex/loaders/categories/tree": (props: any) => getCategoryTree(props?.categoryLevels ?? 3),
|
|
227
|
+
// Commerce passthrough loaders
|
|
228
|
+
"commerce/loaders/navbar.ts": async (props: any) => props.items ?? [],
|
|
229
|
+
"commerce/loaders/product/extensions/detailsPage.ts": async (props: any) => {
|
|
230
|
+
const data = props.data;
|
|
231
|
+
if (data?.product) return data;
|
|
232
|
+
return cachedPDP({ __pagePath: props.__pagePath });
|
|
233
|
+
},
|
|
234
|
+
// requestToParam stub — unresolvable in TanStack, pdpWithSlugFallback bridges it
|
|
235
|
+
"website/functions/requestToParam.ts": async () => null,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Register .ts-less aliases for invoke compatibility
|
|
239
|
+
const withAliases: Record<string, CommerceLoaderFn> = { ...loaders };
|
|
240
|
+
for (const key of Object.keys(loaders)) {
|
|
241
|
+
if (key.endsWith(".ts")) {
|
|
242
|
+
withAliases[key.slice(0, -3)] = loaders[key];
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (options?.extra) {
|
|
247
|
+
Object.assign(withAliases, options.extra);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return withAliases;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Exposes the cached PDP loader for site-specific section loaders that need
|
|
255
|
+
* to call it directly (e.g. ProductDescription fallback).
|
|
256
|
+
*
|
|
257
|
+
* Returns a new instance each call — sites should cache the reference.
|
|
258
|
+
*/
|
|
259
|
+
export function createCachedPDPLoader(profile: CacheProfileName = "product"): CommerceLoaderFn {
|
|
260
|
+
return createCachedLoader("vtex/productDetailsPage", pdpWithSlugFallback, profile);
|
|
261
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `createUseCart` factory.
|
|
3
|
+
*
|
|
4
|
+
* The hook itself depends on React (useState/useEffect), and apps-start
|
|
5
|
+
* does not pull in @testing-library/react. So we test the parts of the
|
|
6
|
+
* factory that don't require a React renderer: shape, isolation, and the
|
|
7
|
+
* pure `itemToAnalyticsItem` helper. Hook semantics are exercised by the
|
|
8
|
+
* site-level integration smoke test (the template has shipped to two
|
|
9
|
+
* production sites).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { type CreateUseCartInvoke, createUseCart } from "../createUseCart";
|
|
14
|
+
|
|
15
|
+
function makeInvoke(): CreateUseCartInvoke {
|
|
16
|
+
const noop = async () => ({ orderFormId: "of-1", items: [] }) as never;
|
|
17
|
+
return {
|
|
18
|
+
vtex: {
|
|
19
|
+
actions: {
|
|
20
|
+
getOrCreateCart: noop,
|
|
21
|
+
addItemsToCart: noop,
|
|
22
|
+
updateCartItems: noop,
|
|
23
|
+
addCouponToCart: noop,
|
|
24
|
+
updateOrderFormAttachment: noop,
|
|
25
|
+
simulateCart: noop,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("createUseCart — factory shape", () => {
|
|
32
|
+
it("returns useCart, resetCart, itemToAnalyticsItem", () => {
|
|
33
|
+
const cart = createUseCart({ invoke: makeInvoke() });
|
|
34
|
+
expect(typeof cart.useCart).toBe("function");
|
|
35
|
+
expect(typeof cart.resetCart).toBe("function");
|
|
36
|
+
expect(typeof cart.itemToAnalyticsItem).toBe("function");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("two factory calls produce independent itemToAnalyticsItem references", () => {
|
|
40
|
+
const a = createUseCart({ invoke: makeInvoke() });
|
|
41
|
+
const b = createUseCart({ invoke: makeInvoke() });
|
|
42
|
+
// Different closures => different function identities.
|
|
43
|
+
expect(a.itemToAnalyticsItem).not.toBe(b.itemToAnalyticsItem);
|
|
44
|
+
expect(a.useCart).not.toBe(b.useCart);
|
|
45
|
+
expect(a.resetCart).not.toBe(b.resetCart);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("accepts custom orderFormCookieName / maxAge without throwing", () => {
|
|
49
|
+
expect(() =>
|
|
50
|
+
createUseCart({
|
|
51
|
+
invoke: makeInvoke(),
|
|
52
|
+
orderFormCookieName: "custom_of_id",
|
|
53
|
+
orderFormCookieMaxAge: 3600,
|
|
54
|
+
}),
|
|
55
|
+
).not.toThrow();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("createUseCart — itemToAnalyticsItem", () => {
|
|
60
|
+
const { itemToAnalyticsItem } = createUseCart({ invoke: makeInvoke() });
|
|
61
|
+
|
|
62
|
+
it("maps the canonical fields", () => {
|
|
63
|
+
const out = itemToAnalyticsItem(
|
|
64
|
+
{
|
|
65
|
+
id: "sku-1",
|
|
66
|
+
productId: "prod-1",
|
|
67
|
+
name: "Widget",
|
|
68
|
+
skuName: "Widget Blue",
|
|
69
|
+
sellingPrice: 1990,
|
|
70
|
+
price: 2490,
|
|
71
|
+
listPrice: 2490,
|
|
72
|
+
quantity: 2,
|
|
73
|
+
seller: "1",
|
|
74
|
+
additionalInfo: { brandName: "Acme" },
|
|
75
|
+
} as never,
|
|
76
|
+
0,
|
|
77
|
+
);
|
|
78
|
+
expect(out).toEqual({
|
|
79
|
+
item_id: "prod-1",
|
|
80
|
+
item_group_id: "prod-1",
|
|
81
|
+
item_name: "Widget",
|
|
82
|
+
item_variant: "Widget Blue",
|
|
83
|
+
item_brand: "Acme",
|
|
84
|
+
price: 19.9,
|
|
85
|
+
discount: 5,
|
|
86
|
+
quantity: 2,
|
|
87
|
+
coupon: undefined,
|
|
88
|
+
affiliation: "1",
|
|
89
|
+
index: 0,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("falls back to skuName when name is missing", () => {
|
|
94
|
+
const out = itemToAnalyticsItem(
|
|
95
|
+
{
|
|
96
|
+
productId: "p",
|
|
97
|
+
skuName: "Fallback",
|
|
98
|
+
sellingPrice: 100,
|
|
99
|
+
listPrice: 100,
|
|
100
|
+
quantity: 1,
|
|
101
|
+
seller: "1",
|
|
102
|
+
} as never,
|
|
103
|
+
3,
|
|
104
|
+
);
|
|
105
|
+
expect(out.item_name).toBe("Fallback");
|
|
106
|
+
expect(out.index).toBe(3);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("uses price when sellingPrice is missing", () => {
|
|
110
|
+
const out = itemToAnalyticsItem(
|
|
111
|
+
{
|
|
112
|
+
productId: "p",
|
|
113
|
+
name: "n",
|
|
114
|
+
price: 1000,
|
|
115
|
+
listPrice: 1000,
|
|
116
|
+
quantity: 1,
|
|
117
|
+
seller: "1",
|
|
118
|
+
} as never,
|
|
119
|
+
0,
|
|
120
|
+
);
|
|
121
|
+
expect(out.price).toBe(10);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("computes zero discount when listPrice equals sellingPrice", () => {
|
|
125
|
+
const out = itemToAnalyticsItem(
|
|
126
|
+
{
|
|
127
|
+
productId: "p",
|
|
128
|
+
name: "n",
|
|
129
|
+
sellingPrice: 500,
|
|
130
|
+
listPrice: 500,
|
|
131
|
+
quantity: 1,
|
|
132
|
+
seller: "1",
|
|
133
|
+
} as never,
|
|
134
|
+
0,
|
|
135
|
+
);
|
|
136
|
+
expect(out.discount).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rounds discount to 2 decimal places", () => {
|
|
140
|
+
const out = itemToAnalyticsItem(
|
|
141
|
+
{
|
|
142
|
+
productId: "p",
|
|
143
|
+
name: "n",
|
|
144
|
+
sellingPrice: 1000,
|
|
145
|
+
listPrice: 1333,
|
|
146
|
+
quantity: 1,
|
|
147
|
+
seller: "1",
|
|
148
|
+
} as never,
|
|
149
|
+
0,
|
|
150
|
+
);
|
|
151
|
+
expect(out.discount).toBe(3.33);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("preserves coupon when supplied", () => {
|
|
155
|
+
const out = itemToAnalyticsItem(
|
|
156
|
+
{
|
|
157
|
+
productId: "p",
|
|
158
|
+
name: "n",
|
|
159
|
+
sellingPrice: 100,
|
|
160
|
+
listPrice: 100,
|
|
161
|
+
quantity: 1,
|
|
162
|
+
seller: "1",
|
|
163
|
+
coupon: "SAVE10",
|
|
164
|
+
} as never,
|
|
165
|
+
0,
|
|
166
|
+
);
|
|
167
|
+
expect(out.coupon).toBe("SAVE10");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("uses empty string brand when additionalInfo is missing", () => {
|
|
171
|
+
const out = itemToAnalyticsItem(
|
|
172
|
+
{
|
|
173
|
+
productId: "p",
|
|
174
|
+
name: "n",
|
|
175
|
+
sellingPrice: 100,
|
|
176
|
+
listPrice: 100,
|
|
177
|
+
quantity: 1,
|
|
178
|
+
seller: "1",
|
|
179
|
+
} as never,
|
|
180
|
+
0,
|
|
181
|
+
);
|
|
182
|
+
expect(out.item_brand).toBe("");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `createUseUser` factory.
|
|
3
|
+
*
|
|
4
|
+
* Same approach as `createUseCart.test.ts`: apps-start does not pull
|
|
5
|
+
* @testing-library/react, so we test factory shape and isolation. Hook
|
|
6
|
+
* semantics are covered by site-level smoke tests.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { type CreateUseUserInvoke, createUseUser } from "../createUseUser";
|
|
11
|
+
|
|
12
|
+
function makeInvoke(): CreateUseUserInvoke {
|
|
13
|
+
return {
|
|
14
|
+
vtex: {
|
|
15
|
+
loaders: {
|
|
16
|
+
user: async () => null,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("createUseUser — factory shape", () => {
|
|
23
|
+
it("returns useUser, resetUser", () => {
|
|
24
|
+
const u = createUseUser({ invoke: makeInvoke() });
|
|
25
|
+
expect(typeof u.useUser).toBe("function");
|
|
26
|
+
expect(typeof u.resetUser).toBe("function");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("two factory calls produce independent function references", () => {
|
|
30
|
+
const a = createUseUser({ invoke: makeInvoke() });
|
|
31
|
+
const b = createUseUser({ invoke: makeInvoke() });
|
|
32
|
+
expect(a.useUser).not.toBe(b.useUser);
|
|
33
|
+
expect(a.resetUser).not.toBe(b.resetUser);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not throw at construction time even with a failing invoke", () => {
|
|
37
|
+
const invoke: CreateUseUserInvoke = {
|
|
38
|
+
vtex: {
|
|
39
|
+
loaders: {
|
|
40
|
+
user: async () => {
|
|
41
|
+
throw new Error("boom");
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
expect(() => createUseUser({ invoke })).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `createUseWishlist` factory.
|
|
3
|
+
*
|
|
4
|
+
* The hook itself depends on React (useState/useEffect), and apps-start
|
|
5
|
+
* does not pull in @testing-library/react. So we test the parts that
|
|
6
|
+
* don't require a renderer: factory shape, isolation, and the pure
|
|
7
|
+
* helpers `findWishlistEntry` + `legacyAddArgsToCanonical` which encode
|
|
8
|
+
* the most error-prone invariants (id matching + legacy arg swap).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import type { WishlistItem } from "../../loaders/wishlist";
|
|
13
|
+
import {
|
|
14
|
+
type CreateUseWishlistInvoke,
|
|
15
|
+
createUseWishlist,
|
|
16
|
+
findWishlistEntry,
|
|
17
|
+
legacyAddArgsToCanonical,
|
|
18
|
+
} from "../createUseWishlist";
|
|
19
|
+
|
|
20
|
+
function makeInvoke(): CreateUseWishlistInvoke {
|
|
21
|
+
return {
|
|
22
|
+
vtex: {
|
|
23
|
+
loaders: {
|
|
24
|
+
wishlist: async () => [],
|
|
25
|
+
},
|
|
26
|
+
actions: {
|
|
27
|
+
addToWishlist: async () => [],
|
|
28
|
+
removeFromWishlist: async () => [],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("createUseWishlist — factory shape", () => {
|
|
35
|
+
it("returns useWishlist, resetWishlist", () => {
|
|
36
|
+
const w = createUseWishlist({ invoke: makeInvoke() });
|
|
37
|
+
expect(typeof w.useWishlist).toBe("function");
|
|
38
|
+
expect(typeof w.resetWishlist).toBe("function");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("two factory calls produce independent function references", () => {
|
|
42
|
+
const a = createUseWishlist({ invoke: makeInvoke() });
|
|
43
|
+
const b = createUseWishlist({ invoke: makeInvoke() });
|
|
44
|
+
expect(a.useWishlist).not.toBe(b.useWishlist);
|
|
45
|
+
expect(a.resetWishlist).not.toBe(b.resetWishlist);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("legacyAddArgsToCanonical — arg swap", () => {
|
|
50
|
+
it("maps legacy (productId=sku, productGroupId=parent) to canonical { productId, sku }", () => {
|
|
51
|
+
// In analytics terms: item_id is the SKU; item_group_id is the
|
|
52
|
+
// VTEX productId. Sites destructure these from analytics objects
|
|
53
|
+
// and pass them to wishlist.addItem in that order.
|
|
54
|
+
const out = legacyAddArgsToCanonical("sku-1", "prod-100");
|
|
55
|
+
expect(out).toEqual({ productId: "prod-100", sku: "sku-1" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("preserves empty strings without coercing", () => {
|
|
59
|
+
const out = legacyAddArgsToCanonical("", "");
|
|
60
|
+
expect(out).toEqual({ productId: "", sku: "" });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("findWishlistEntry — id matching", () => {
|
|
65
|
+
const items: WishlistItem[] = [
|
|
66
|
+
{ id: "entry-1", productId: "prod-1", sku: "sku-1", title: "A" },
|
|
67
|
+
{ id: "entry-2", productId: "prod-2", sku: "sku-2", title: "B" },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
it("matches by sku (the legacy `productId` arg passed by sites)", () => {
|
|
71
|
+
const entry = findWishlistEntry(items, "sku-1");
|
|
72
|
+
expect(entry?.id).toBe("entry-1");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("matches by productId as a fallback", () => {
|
|
76
|
+
const entry = findWishlistEntry(items, "prod-2");
|
|
77
|
+
expect(entry?.id).toBe("entry-2");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns undefined when no match", () => {
|
|
81
|
+
expect(findWishlistEntry(items, "missing")).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns undefined for empty list", () => {
|
|
85
|
+
expect(findWishlistEntry([], "anything")).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|