@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,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product Extension Pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Composable middleware-style pipeline to enrich products after the
|
|
5
|
+
* initial search/catalog fetch. Covers real-time price simulation
|
|
6
|
+
* (for B2B/promotional pricing) and wishlist annotation.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import {
|
|
11
|
+
* createProductPipeline,
|
|
12
|
+
* withSimulation,
|
|
13
|
+
* withWishlist,
|
|
14
|
+
* } from "@decocms/apps/vtex/utils/enrichment";
|
|
15
|
+
*
|
|
16
|
+
* const enrich = createProductPipeline(
|
|
17
|
+
* withSimulation(),
|
|
18
|
+
* withWishlist(),
|
|
19
|
+
* );
|
|
20
|
+
*
|
|
21
|
+
* const products = await vtexProductList(props);
|
|
22
|
+
* const enriched = await enrich(products, { request });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Product, ProductLeaf } from "@decocms/apps-commerce/types";
|
|
27
|
+
import { getVtexConfig, vtexFetch, vtexIOGraphQL } from "../client";
|
|
28
|
+
import { listBrands } from "../loaders/brands";
|
|
29
|
+
import { batch } from "./batch";
|
|
30
|
+
import { withIsSimilarTo } from "./similars";
|
|
31
|
+
import { pickSku, toInventories, toProduct, toReview } from "./transform";
|
|
32
|
+
import type { LegacyProduct } from "./types";
|
|
33
|
+
import { buildAuthCookieHeader, VTEX_AUTH_COOKIE } from "./vtexId";
|
|
34
|
+
|
|
35
|
+
// -------------------------------------------------------------------------
|
|
36
|
+
// Constants
|
|
37
|
+
// -------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** VTEX prices come in cents — divide by this to get the currency value. */
|
|
40
|
+
const CENTS_DIVISOR = 100;
|
|
41
|
+
|
|
42
|
+
/** Default number of products per simulation API call. */
|
|
43
|
+
const DEFAULT_SIMULATION_BATCH_SIZE = 50;
|
|
44
|
+
|
|
45
|
+
/** Maximum wishlist items to fetch in a single query. */
|
|
46
|
+
const WISHLIST_MAX_ITEMS = 500;
|
|
47
|
+
|
|
48
|
+
/** Batch size for kit-item product lookups. */
|
|
49
|
+
const KIT_ITEMS_BATCH_SIZE = 10;
|
|
50
|
+
|
|
51
|
+
/** Batch size for variant product lookups. */
|
|
52
|
+
const VARIANTS_BATCH_SIZE = 15;
|
|
53
|
+
|
|
54
|
+
/** Number of reviews to fetch per product. */
|
|
55
|
+
const REVIEWS_PAGE_SIZE = 10;
|
|
56
|
+
|
|
57
|
+
// -------------------------------------------------------------------------
|
|
58
|
+
// Types
|
|
59
|
+
// -------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
export interface EnrichmentContext {
|
|
62
|
+
/** The incoming HTTP request (for cookies, auth tokens). */
|
|
63
|
+
request?: Request;
|
|
64
|
+
/** Sales channel override. */
|
|
65
|
+
salesChannel?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A product enricher takes a list of products and returns an enriched list.
|
|
70
|
+
* Enrichers are composed via `createProductPipeline`.
|
|
71
|
+
*/
|
|
72
|
+
export type ProductEnricher = (products: Product[], ctx: EnrichmentContext) => Promise<Product[]>;
|
|
73
|
+
|
|
74
|
+
// -------------------------------------------------------------------------
|
|
75
|
+
// Pipeline
|
|
76
|
+
// -------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compose multiple enrichers into a single pipeline.
|
|
80
|
+
*
|
|
81
|
+
* Enrichers run sequentially -- each receives the output of the previous.
|
|
82
|
+
* This is intentional: some enrichers depend on previous enrichments
|
|
83
|
+
* (e.g., wishlist may need SKU IDs added by simulation).
|
|
84
|
+
*/
|
|
85
|
+
export function createProductPipeline(...enrichers: ProductEnricher[]): ProductEnricher {
|
|
86
|
+
return async (products, ctx) => {
|
|
87
|
+
if (!products.length) return products;
|
|
88
|
+
|
|
89
|
+
let result = products;
|
|
90
|
+
for (const enricher of enrichers) {
|
|
91
|
+
try {
|
|
92
|
+
result = await enricher(result, ctx);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error(
|
|
95
|
+
`[ProductPipeline] Enricher failed, continuing with unenriched data:`,
|
|
96
|
+
error instanceof Error ? error.message : error,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// -------------------------------------------------------------------------
|
|
105
|
+
// Simulation Enricher
|
|
106
|
+
// -------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
interface SimulationItem {
|
|
109
|
+
itemIndex: number;
|
|
110
|
+
id: string;
|
|
111
|
+
quantity: number;
|
|
112
|
+
seller: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface SimulationResult {
|
|
116
|
+
items: Array<{
|
|
117
|
+
itemIndex: number;
|
|
118
|
+
listPrice: number;
|
|
119
|
+
sellingPrice: number;
|
|
120
|
+
price: number;
|
|
121
|
+
availability: string;
|
|
122
|
+
quantity: number;
|
|
123
|
+
}>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Enrich products with real-time prices from VTEX simulation API.
|
|
128
|
+
*
|
|
129
|
+
* The search index may have stale prices. Simulation returns the
|
|
130
|
+
* actual price the user would pay, accounting for promotions,
|
|
131
|
+
* trade policies, price tables, and regional pricing.
|
|
132
|
+
*
|
|
133
|
+
* @param options.batchSize - Max products per simulation call. @default 50
|
|
134
|
+
*/
|
|
135
|
+
export function withSimulation(options?: { batchSize?: number }): ProductEnricher {
|
|
136
|
+
const batchSize = options?.batchSize ?? DEFAULT_SIMULATION_BATCH_SIZE;
|
|
137
|
+
|
|
138
|
+
return async (products, ctx) => {
|
|
139
|
+
const config = getVtexConfig();
|
|
140
|
+
const sc = ctx.salesChannel ?? config.salesChannel ?? "1";
|
|
141
|
+
|
|
142
|
+
const skuItems: SimulationItem[] = [];
|
|
143
|
+
const skuToProductIndex = new Map<string, { productIdx: number; offerIdx: number }>();
|
|
144
|
+
|
|
145
|
+
for (let pi = 0; pi < products.length; pi++) {
|
|
146
|
+
const product = products[pi];
|
|
147
|
+
const aggOffer = product.offers;
|
|
148
|
+
if (!aggOffer?.offers) continue;
|
|
149
|
+
|
|
150
|
+
for (let oi = 0; oi < aggOffer.offers.length; oi++) {
|
|
151
|
+
const offer = aggOffer.offers[oi];
|
|
152
|
+
const skuId = product.sku ?? product.productID;
|
|
153
|
+
const seller = offer.seller ?? "1";
|
|
154
|
+
|
|
155
|
+
if (skuId) {
|
|
156
|
+
skuItems.push({
|
|
157
|
+
itemIndex: skuItems.length,
|
|
158
|
+
id: skuId,
|
|
159
|
+
quantity: 1,
|
|
160
|
+
seller,
|
|
161
|
+
});
|
|
162
|
+
skuToProductIndex.set(`${skuId}-${seller}`, {
|
|
163
|
+
productIdx: pi,
|
|
164
|
+
offerIdx: oi,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!skuItems.length) return products;
|
|
171
|
+
|
|
172
|
+
const result = [...products];
|
|
173
|
+
const batches: SimulationItem[][] = [];
|
|
174
|
+
for (let i = 0; i < skuItems.length; i += batchSize) {
|
|
175
|
+
batches.push(skuItems.slice(i, i + batchSize));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const batch of batches) {
|
|
179
|
+
try {
|
|
180
|
+
const sim = await vtexFetch<SimulationResult>(
|
|
181
|
+
`/api/checkout/pub/orderForms/simulation?sc=${sc}&RnbBehavior=1`,
|
|
182
|
+
{
|
|
183
|
+
method: "POST",
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
items: batch,
|
|
186
|
+
country: config.country ?? "BRA",
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
for (const simItem of sim.items) {
|
|
192
|
+
const original = batch[simItem.itemIndex];
|
|
193
|
+
if (!original) continue;
|
|
194
|
+
|
|
195
|
+
const key = `${original.id}-${original.seller}`;
|
|
196
|
+
const mapping = skuToProductIndex.get(key);
|
|
197
|
+
if (!mapping) continue;
|
|
198
|
+
|
|
199
|
+
const product = { ...result[mapping.productIdx] };
|
|
200
|
+
const aggOffer = product.offers;
|
|
201
|
+
if (!aggOffer) continue;
|
|
202
|
+
|
|
203
|
+
const offers = [...aggOffer.offers];
|
|
204
|
+
const offer = { ...offers[mapping.offerIdx] };
|
|
205
|
+
|
|
206
|
+
offer.price = simItem.sellingPrice / CENTS_DIVISOR;
|
|
207
|
+
if (simItem.listPrice) {
|
|
208
|
+
(offer as any).priceSpecification = [
|
|
209
|
+
...(Array.isArray((offer as any).priceSpecification)
|
|
210
|
+
? (offer as any).priceSpecification
|
|
211
|
+
: []),
|
|
212
|
+
].map((spec: any) => {
|
|
213
|
+
if (spec?.priceType === "https://schema.org/ListPrice") {
|
|
214
|
+
return { ...spec, price: simItem.listPrice / CENTS_DIVISOR };
|
|
215
|
+
}
|
|
216
|
+
if (spec?.priceType === "https://schema.org/SalePrice") {
|
|
217
|
+
return { ...spec, price: simItem.sellingPrice / CENTS_DIVISOR };
|
|
218
|
+
}
|
|
219
|
+
return spec;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
offer.availability =
|
|
223
|
+
simItem.availability === "available"
|
|
224
|
+
? "https://schema.org/InStock"
|
|
225
|
+
: "https://schema.org/OutOfStock";
|
|
226
|
+
|
|
227
|
+
offers[mapping.offerIdx] = offer;
|
|
228
|
+
product.offers = { ...aggOffer, offers };
|
|
229
|
+
result[mapping.productIdx] = product;
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.error("[Simulation] Batch failed:", error instanceof Error ? error.message : error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return result;
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// -------------------------------------------------------------------------
|
|
241
|
+
// Wishlist Enricher
|
|
242
|
+
// -------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
const WISHLIST_QUERY = `query GetWishlist($shopperId: String!, $name: String!, $from: Int!, $to: Int!) {
|
|
245
|
+
viewList(shopperId: $shopperId, name: $name, from: $from, to: $to)
|
|
246
|
+
@context(provider: "vtex.wish-list@1.x") {
|
|
247
|
+
data {
|
|
248
|
+
id
|
|
249
|
+
productId
|
|
250
|
+
sku
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}`;
|
|
254
|
+
|
|
255
|
+
interface WishlistData {
|
|
256
|
+
viewList: {
|
|
257
|
+
data: Array<{ id: string; productId: string; sku: string }> | null;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getCookieValue(cookieHeader: string, name: string): string | null {
|
|
262
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
263
|
+
return match?.[1] ?? null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Enrich products with wishlist status.
|
|
268
|
+
*
|
|
269
|
+
* Reads the user's wishlist and adds `isInWishlist: true` as an
|
|
270
|
+
* additionalProperty on products that are wishlisted.
|
|
271
|
+
*
|
|
272
|
+
* Requires the user to be logged in (reads VtexIdclientAutCookie).
|
|
273
|
+
* For anonymous users, this is a no-op.
|
|
274
|
+
*/
|
|
275
|
+
export function withWishlist(): ProductEnricher {
|
|
276
|
+
return async (products, ctx) => {
|
|
277
|
+
if (!ctx.request) return products;
|
|
278
|
+
|
|
279
|
+
const cookies = ctx.request.headers.get("cookie") ?? "";
|
|
280
|
+
const authCookie = getCookieValue(cookies, VTEX_AUTH_COOKIE);
|
|
281
|
+
if (!authCookie) return products;
|
|
282
|
+
|
|
283
|
+
let email: string | undefined;
|
|
284
|
+
try {
|
|
285
|
+
const parts = authCookie.split(".");
|
|
286
|
+
if (parts.length === 3) {
|
|
287
|
+
const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
288
|
+
email = payload.sub ?? payload.userId;
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
return products;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!email) return products;
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const data = await vtexIOGraphQL<WishlistData>(
|
|
298
|
+
{
|
|
299
|
+
query: WISHLIST_QUERY,
|
|
300
|
+
variables: { shopperId: email, name: "Wishlist", from: 0, to: WISHLIST_MAX_ITEMS },
|
|
301
|
+
},
|
|
302
|
+
{ Cookie: buildAuthCookieHeader(authCookie, getVtexConfig().account) },
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const wishlistItems = data.viewList?.data ?? [];
|
|
306
|
+
const wishlistSkus = new Set(wishlistItems.map((i) => i.sku));
|
|
307
|
+
const wishlistProductIds = new Set(wishlistItems.map((i) => i.productId));
|
|
308
|
+
|
|
309
|
+
return products.map((product) => {
|
|
310
|
+
const isWishlisted =
|
|
311
|
+
(product.sku && wishlistSkus.has(product.sku)) ||
|
|
312
|
+
(product.productID && wishlistProductIds.has(product.productID));
|
|
313
|
+
|
|
314
|
+
if (!isWishlisted) return product;
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
...product,
|
|
318
|
+
additionalProperty: [
|
|
319
|
+
...(product.additionalProperty ?? []),
|
|
320
|
+
{
|
|
321
|
+
"@type": "PropertyValue" as const,
|
|
322
|
+
name: "isInWishlist",
|
|
323
|
+
value: "true",
|
|
324
|
+
propertyID: "WISHLIST",
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error(
|
|
331
|
+
"[Wishlist] Failed to fetch wishlist:",
|
|
332
|
+
error instanceof Error ? error.message : error,
|
|
333
|
+
);
|
|
334
|
+
return products;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// -------------------------------------------------------------------------
|
|
340
|
+
// Similars Enricher
|
|
341
|
+
// -------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Enrich products with similar product data from Legacy Catalog API.
|
|
345
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (similarsExt)
|
|
346
|
+
*/
|
|
347
|
+
export function withSimilars(): ProductEnricher {
|
|
348
|
+
return async (products) => {
|
|
349
|
+
return Promise.all(products.map((p) => withIsSimilarTo(p)));
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// -------------------------------------------------------------------------
|
|
354
|
+
// Kit Items Enricher
|
|
355
|
+
// -------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Enrich products with kit item details (isAccessoryOrSparePartFor).
|
|
359
|
+
* Fetches full product data for referenced accessories via Legacy Catalog.
|
|
360
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (kitItemsExt)
|
|
361
|
+
*/
|
|
362
|
+
export function withKitItems(): ProductEnricher {
|
|
363
|
+
return async (products) => {
|
|
364
|
+
const productIDs = new Set<string>();
|
|
365
|
+
|
|
366
|
+
for (const product of products) {
|
|
367
|
+
for (const item of product.isAccessoryOrSparePartFor ?? []) {
|
|
368
|
+
if (item.productID) productIDs.add(item.productID);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!productIDs.size) return products;
|
|
373
|
+
|
|
374
|
+
const config = getVtexConfig();
|
|
375
|
+
const baseUrl = config.publicUrl
|
|
376
|
+
? `https://${config.publicUrl}`
|
|
377
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
378
|
+
|
|
379
|
+
const batches = batch([...productIDs], KIT_ITEMS_BATCH_SIZE);
|
|
380
|
+
const productsById = new Map<string, ProductLeaf>();
|
|
381
|
+
|
|
382
|
+
for (const ids of batches) {
|
|
383
|
+
try {
|
|
384
|
+
const fq = ids.map((id) => `productId:${id}`);
|
|
385
|
+
const raw = await vtexFetch<LegacyProduct[]>(
|
|
386
|
+
`/api/catalog_system/pub/products/search/?${fq.map((f) => `fq=${f}`).join("&")}&_from=0&_to=${ids.length - 1}`,
|
|
387
|
+
);
|
|
388
|
+
for (const p of raw) {
|
|
389
|
+
const sku = pickSku(p);
|
|
390
|
+
const product = toProduct(p, sku, 0, {
|
|
391
|
+
baseUrl,
|
|
392
|
+
priceCurrency: "BRL",
|
|
393
|
+
});
|
|
394
|
+
for (const leaf of product.isVariantOf?.hasVariant ?? []) {
|
|
395
|
+
productsById.set(leaf.productID, leaf);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} catch (e) {
|
|
399
|
+
console.error("[KitItems] Batch failed:", e instanceof Error ? e.message : e);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return products.map((p) => ({
|
|
404
|
+
...p,
|
|
405
|
+
isAccessoryOrSparePartFor: p.isAccessoryOrSparePartFor
|
|
406
|
+
?.map((item) => productsById.get(item.productID))
|
|
407
|
+
.filter((item): item is ProductLeaf => Boolean(item)),
|
|
408
|
+
}));
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// -------------------------------------------------------------------------
|
|
413
|
+
// Variants Enricher
|
|
414
|
+
// -------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Enrich products with full variant data from Legacy Catalog.
|
|
418
|
+
* When products come from IS, they may lack variant details.
|
|
419
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (variantsExt)
|
|
420
|
+
*/
|
|
421
|
+
export function withVariants(): ProductEnricher {
|
|
422
|
+
return async (products) => {
|
|
423
|
+
const productIDs = new Set<string>();
|
|
424
|
+
for (const product of products) {
|
|
425
|
+
if (product.productID) productIDs.add(product.productID);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!productIDs.size) return products;
|
|
429
|
+
|
|
430
|
+
const config = getVtexConfig();
|
|
431
|
+
const baseUrl = config.publicUrl
|
|
432
|
+
? `https://${config.publicUrl}`
|
|
433
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
434
|
+
|
|
435
|
+
const batches = batch([...productIDs], VARIANTS_BATCH_SIZE);
|
|
436
|
+
const productsById = new Map<string, Product>();
|
|
437
|
+
|
|
438
|
+
for (const ids of batches) {
|
|
439
|
+
try {
|
|
440
|
+
const fq = ids.map((id) => `productId:${id}`);
|
|
441
|
+
const raw = await vtexFetch<LegacyProduct[]>(
|
|
442
|
+
`/api/catalog_system/pub/products/search/?${fq.map((f) => `fq=${f}`).join("&")}&_from=0&_to=${ids.length - 1}`,
|
|
443
|
+
);
|
|
444
|
+
for (const p of raw) {
|
|
445
|
+
const sku = pickSku(p);
|
|
446
|
+
const product = toProduct(p, sku, 0, {
|
|
447
|
+
baseUrl,
|
|
448
|
+
priceCurrency: "BRL",
|
|
449
|
+
});
|
|
450
|
+
productsById.set(product.productID, product);
|
|
451
|
+
}
|
|
452
|
+
} catch (e) {
|
|
453
|
+
console.error("[Variants] Batch failed:", e instanceof Error ? e.message : e);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return products.map((p) => ({
|
|
458
|
+
...productsById.get(p.productID),
|
|
459
|
+
...p,
|
|
460
|
+
isVariantOf: productsById.get(p.productID)?.isVariantOf,
|
|
461
|
+
}));
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// -------------------------------------------------------------------------
|
|
466
|
+
// Reviews Enricher
|
|
467
|
+
// -------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Enrich products with reviews and ratings from VTEX Reviews & Ratings app.
|
|
471
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (reviewsExt)
|
|
472
|
+
*/
|
|
473
|
+
export function withReviews(): ProductEnricher {
|
|
474
|
+
return async (products) => {
|
|
475
|
+
const config = getVtexConfig();
|
|
476
|
+
const myHost = `${config.account}.myvtex.com`;
|
|
477
|
+
|
|
478
|
+
const reviewPromises = products.map((product) =>
|
|
479
|
+
vtexFetch<any>(
|
|
480
|
+
`https://${myHost}/reviews-and-ratings/api/reviews?product_id=${product.inProductGroupWithID ?? ""}&from=0&to=${REVIEWS_PAGE_SIZE}&status=true`,
|
|
481
|
+
).catch((error) => {
|
|
482
|
+
console.error(
|
|
483
|
+
"[Reviews] Failed for product",
|
|
484
|
+
product.inProductGroupWithID,
|
|
485
|
+
error instanceof Error ? error.message : error,
|
|
486
|
+
);
|
|
487
|
+
return {};
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
const ratingPromises = products.map((product) =>
|
|
492
|
+
vtexFetch<any>(
|
|
493
|
+
`https://${myHost}/reviews-and-ratings/api/rating/${product.inProductGroupWithID ?? ""}`,
|
|
494
|
+
).catch((error) => {
|
|
495
|
+
console.error(
|
|
496
|
+
"[Ratings] Failed for product",
|
|
497
|
+
product.inProductGroupWithID,
|
|
498
|
+
error instanceof Error ? error.message : error,
|
|
499
|
+
);
|
|
500
|
+
return {};
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const [reviews, ratings] = await Promise.all([
|
|
505
|
+
Promise.all(reviewPromises),
|
|
506
|
+
Promise.all(ratingPromises),
|
|
507
|
+
]);
|
|
508
|
+
|
|
509
|
+
return toReview(products, ratings, reviews);
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// -------------------------------------------------------------------------
|
|
514
|
+
// Inventory Enricher
|
|
515
|
+
// -------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Enrich products with inventory/stock data from VTEX Logistics API.
|
|
519
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (inventoryExt)
|
|
520
|
+
*/
|
|
521
|
+
export function withInventory(): ProductEnricher {
|
|
522
|
+
return async (products) => {
|
|
523
|
+
const inventories = await Promise.all(
|
|
524
|
+
products.map((product) => {
|
|
525
|
+
if (!product.sku) return Promise.resolve({});
|
|
526
|
+
return vtexFetch<any>(`/api/logistics/pvt/inventory/skus/${product.sku}`).catch((error) => {
|
|
527
|
+
console.error(
|
|
528
|
+
"[Inventory] Failed for SKU",
|
|
529
|
+
product.sku,
|
|
530
|
+
error instanceof Error ? error.message : error,
|
|
531
|
+
);
|
|
532
|
+
return {};
|
|
533
|
+
});
|
|
534
|
+
}),
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
return toInventories(products, inventories);
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// -------------------------------------------------------------------------
|
|
542
|
+
// Brands Enricher
|
|
543
|
+
// -------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Enrich products with brand information from Legacy Catalog.
|
|
547
|
+
* Useful for Intelligent Search results that may lack brand details.
|
|
548
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (brandsExt)
|
|
549
|
+
*/
|
|
550
|
+
export function withBrands(): ProductEnricher {
|
|
551
|
+
return async (products) => {
|
|
552
|
+
const brands = await listBrands();
|
|
553
|
+
if (!brands?.length) return products;
|
|
554
|
+
|
|
555
|
+
return products.map((p) => {
|
|
556
|
+
const match = brands.find((b) => b["@id"] === p.brand?.["@id"]);
|
|
557
|
+
return match ? { ...p, brand: match } : p;
|
|
558
|
+
});
|
|
559
|
+
};
|
|
560
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic VTEX fetch helpers.
|
|
3
|
+
*
|
|
4
|
+
* These are the canonical primitives for ad-hoc VTEX API calls in apps-start
|
|
5
|
+
* (custom path-resolution loaders, sitemap loaders, custom analytics, etc.).
|
|
6
|
+
* For typed catalog/IS/checkout calls prefer the {@link import("../client").vtexFetch}
|
|
7
|
+
* client which is wired into the configured account.
|
|
8
|
+
*
|
|
9
|
+
* Provides:
|
|
10
|
+
* - {@link fetchSafe} — fetch wrapper that throws {@link HttpError} on non-2xx
|
|
11
|
+
* - {@link fetchAPI} — same, parsed to JSON
|
|
12
|
+
*
|
|
13
|
+
* URLs are sanitized for known XSS-prone query params (utm_*, ft, map) before
|
|
14
|
+
* dispatch. This mirrors the security posture VTEX storefronts carry across
|
|
15
|
+
* the platform.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type CachingMode = "stale-while-revalidate";
|
|
19
|
+
|
|
20
|
+
type DecoInit = {
|
|
21
|
+
cache: CachingMode;
|
|
22
|
+
cacheTtlByStatus?: Array<{ from: number; to: number; ttl: number }>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type DecoRequestInit = RequestInit & { deco?: DecoInit };
|
|
26
|
+
|
|
27
|
+
export class HttpError extends Error {
|
|
28
|
+
readonly status: number;
|
|
29
|
+
readonly response: Response;
|
|
30
|
+
|
|
31
|
+
constructor(response: Response) {
|
|
32
|
+
super(`HTTP ${response.status} ${response.statusText} — ${response.url}`);
|
|
33
|
+
this.name = "HttpError";
|
|
34
|
+
this.status = response.status;
|
|
35
|
+
this.response = response;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const removeNonLatin1Chars = (str: string): string =>
|
|
40
|
+
// eslint-disable-next-line no-control-regex
|
|
41
|
+
str.replace(/[^\x00-\xFF]/g, "");
|
|
42
|
+
|
|
43
|
+
const removeScriptChars = (str: string): string => str.replace(/[<>]/g, "");
|
|
44
|
+
|
|
45
|
+
const QS_TO_REMOVE_PLUS = ["utm_campaign", "utm_medium", "utm_source", "map"];
|
|
46
|
+
const QS_TO_REPLACE_PLUS = ["ft"];
|
|
47
|
+
|
|
48
|
+
const sanitizeUrl = (input: string | URL | Request): string | Request | URL => {
|
|
49
|
+
let url: URL;
|
|
50
|
+
|
|
51
|
+
if (typeof input === "string") {
|
|
52
|
+
try {
|
|
53
|
+
url = new URL(input);
|
|
54
|
+
} catch {
|
|
55
|
+
return input;
|
|
56
|
+
}
|
|
57
|
+
} else if (input instanceof URL) {
|
|
58
|
+
url = input;
|
|
59
|
+
} else {
|
|
60
|
+
return input;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const key of QS_TO_REMOVE_PLUS) {
|
|
64
|
+
if (!url.searchParams.has(key)) continue;
|
|
65
|
+
const values = url.searchParams.getAll(key);
|
|
66
|
+
const cleaned = values.map((v) => removeScriptChars(removeNonLatin1Chars(v))).filter(Boolean);
|
|
67
|
+
url.searchParams.delete(key);
|
|
68
|
+
for (const v of cleaned) url.searchParams.append(key, v);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const key of QS_TO_REPLACE_PLUS) {
|
|
72
|
+
if (!url.searchParams.has(key)) continue;
|
|
73
|
+
const values = url.searchParams.getAll(key);
|
|
74
|
+
const cleaned = values.map((v) => encodeURIComponent(v.trim()));
|
|
75
|
+
url.searchParams.delete(key);
|
|
76
|
+
for (const v of cleaned) url.searchParams.append(key, v);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return url.toString();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fetch wrapper that throws {@link HttpError} on non-2xx responses and
|
|
84
|
+
* sanitizes URL query strings.
|
|
85
|
+
*/
|
|
86
|
+
export async function fetchSafe(
|
|
87
|
+
input: string | URL | Request,
|
|
88
|
+
init?: DecoRequestInit,
|
|
89
|
+
): Promise<Response> {
|
|
90
|
+
const sanitized = sanitizeUrl(input);
|
|
91
|
+
const response = await fetch(sanitized as RequestInfo, init);
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new HttpError(response);
|
|
94
|
+
}
|
|
95
|
+
return response;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch wrapper that parses the response as JSON. Throws on non-2xx.
|
|
100
|
+
*/
|
|
101
|
+
export async function fetchAPI<T = unknown>(
|
|
102
|
+
input: string | URL | Request,
|
|
103
|
+
init?: DecoRequestInit,
|
|
104
|
+
): Promise<T> {
|
|
105
|
+
const response = await fetchSafe(input, init);
|
|
106
|
+
return response.json() as Promise<T>;
|
|
107
|
+
}
|