@decocms/apps 0.20.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/.github/workflows/release.yml +34 -0
- package/.releaserc.json +25 -0
- package/commerce/components/Image.tsx +209 -0
- package/commerce/components/JsonLd.tsx +285 -0
- package/commerce/sdk/analytics.ts +24 -0
- package/commerce/sdk/formatPrice.ts +23 -0
- package/commerce/sdk/url.ts +9 -0
- package/commerce/sdk/useOffer.ts +75 -0
- package/commerce/sdk/useVariantPossibilities.ts +43 -0
- package/commerce/types/commerce.ts +1105 -0
- package/commerce/utils/canonical.ts +11 -0
- package/commerce/utils/constants.ts +9 -0
- package/commerce/utils/filters.ts +10 -0
- package/commerce/utils/productToAnalyticsItem.ts +67 -0
- package/commerce/utils/stateByZip.ts +50 -0
- package/knip.json +19 -0
- package/package.json +77 -0
- package/shopify/actions/cart/addItems.ts +37 -0
- package/shopify/actions/cart/updateCoupons.ts +32 -0
- package/shopify/actions/cart/updateItems.ts +32 -0
- package/shopify/actions/user/signIn.ts +45 -0
- package/shopify/actions/user/signUp.ts +36 -0
- package/shopify/client.ts +58 -0
- package/shopify/index.ts +32 -0
- package/shopify/init.ts +40 -0
- package/shopify/loaders/ProductDetailsPage.ts +35 -0
- package/shopify/loaders/ProductList.ts +101 -0
- package/shopify/loaders/ProductListingPage.ts +180 -0
- package/shopify/loaders/RelatedProducts.ts +45 -0
- package/shopify/loaders/cart.ts +73 -0
- package/shopify/loaders/shop.ts +40 -0
- package/shopify/loaders/user.ts +44 -0
- package/shopify/utils/admin/admin.ts +57 -0
- package/shopify/utils/admin/queries.ts +29 -0
- package/shopify/utils/cart.ts +28 -0
- package/shopify/utils/cookies.ts +85 -0
- package/shopify/utils/enums.ts +438 -0
- package/shopify/utils/graphql.ts +69 -0
- package/shopify/utils/storefront/queries.ts +530 -0
- package/shopify/utils/storefront/storefront.graphql.gen.ts +113 -0
- package/shopify/utils/transform.ts +436 -0
- package/shopify/utils/types.ts +191 -0
- package/shopify/utils/user.ts +23 -0
- package/shopify/utils/utils.ts +164 -0
- package/tsconfig.json +11 -0
- package/vtex/README.md +6 -0
- package/vtex/actions/address.ts +211 -0
- package/vtex/actions/auth.ts +337 -0
- package/vtex/actions/checkout.ts +497 -0
- package/vtex/actions/index.ts +11 -0
- package/vtex/actions/masterData.ts +170 -0
- package/vtex/actions/misc.ts +196 -0
- package/vtex/actions/newsletter.ts +108 -0
- package/vtex/actions/orders.ts +37 -0
- package/vtex/actions/profile.ts +119 -0
- package/vtex/actions/session.ts +87 -0
- package/vtex/actions/trigger.ts +43 -0
- package/vtex/actions/wishlist.ts +116 -0
- package/vtex/client.ts +423 -0
- package/vtex/hooks/index.ts +4 -0
- package/vtex/hooks/useAutocomplete.ts +89 -0
- package/vtex/hooks/useCart.ts +219 -0
- package/vtex/hooks/useUser.ts +78 -0
- package/vtex/hooks/useWishlist.ts +119 -0
- package/vtex/index.ts +14 -0
- package/vtex/inline-loaders/productDetailsPage.ts +75 -0
- package/vtex/inline-loaders/productList.ts +163 -0
- package/vtex/inline-loaders/productListingPage.ts +447 -0
- package/vtex/inline-loaders/relatedProducts.ts +83 -0
- package/vtex/inline-loaders/suggestions.ts +49 -0
- package/vtex/inline-loaders/workflowProducts.ts +68 -0
- package/vtex/invoke.ts +202 -0
- package/vtex/loaders/address.ts +120 -0
- package/vtex/loaders/brands.ts +51 -0
- package/vtex/loaders/cart.ts +49 -0
- package/vtex/loaders/catalog.ts +165 -0
- package/vtex/loaders/collections.ts +57 -0
- package/vtex/loaders/index.ts +19 -0
- package/vtex/loaders/legacy.ts +671 -0
- package/vtex/loaders/logistics.ts +115 -0
- package/vtex/loaders/navbar.ts +29 -0
- package/vtex/loaders/orders.ts +103 -0
- package/vtex/loaders/pageType.ts +62 -0
- package/vtex/loaders/payment.ts +107 -0
- package/vtex/loaders/profile.ts +138 -0
- package/vtex/loaders/promotion.ts +33 -0
- package/vtex/loaders/search.ts +127 -0
- package/vtex/loaders/session.ts +91 -0
- package/vtex/loaders/user.ts +89 -0
- package/vtex/loaders/wishlist.ts +89 -0
- package/vtex/loaders/wishlistProducts.ts +81 -0
- package/vtex/loaders/workflow.ts +323 -0
- package/vtex/logo.png +0 -0
- package/vtex/middleware.ts +229 -0
- package/vtex/types.ts +248 -0
- package/vtex/utils/batch.ts +21 -0
- package/vtex/utils/cookies.ts +76 -0
- package/vtex/utils/enrichment.ts +540 -0
- package/vtex/utils/fetchCache.ts +150 -0
- package/vtex/utils/index.ts +17 -0
- package/vtex/utils/intelligentSearch.ts +84 -0
- package/vtex/utils/legacy.ts +155 -0
- package/vtex/utils/pickAndOmit.ts +30 -0
- package/vtex/utils/proxy.ts +196 -0
- package/vtex/utils/resourceRange.ts +10 -0
- package/vtex/utils/segment.ts +163 -0
- package/vtex/utils/similars.ts +38 -0
- package/vtex/utils/sitemap.ts +133 -0
- package/vtex/utils/slugCache.ts +32 -0
- package/vtex/utils/slugify.ts +13 -0
- package/vtex/utils/transform.ts +1331 -0
- package/vtex/utils/types.ts +1884 -0
- package/vtex/utils/vtexId.ts +103 -0
|
@@ -0,0 +1,540 @@
|
|
|
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 "../../commerce/types/commerce";
|
|
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
|
+
// Types
|
|
37
|
+
// -------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export interface EnrichmentContext {
|
|
40
|
+
/** The incoming HTTP request (for cookies, auth tokens). */
|
|
41
|
+
request?: Request;
|
|
42
|
+
/** Sales channel override. */
|
|
43
|
+
salesChannel?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A product enricher takes a list of products and returns an enriched list.
|
|
48
|
+
* Enrichers are composed via `createProductPipeline`.
|
|
49
|
+
*/
|
|
50
|
+
export type ProductEnricher = (
|
|
51
|
+
products: Product[],
|
|
52
|
+
ctx: EnrichmentContext,
|
|
53
|
+
) => Promise<Product[]>;
|
|
54
|
+
|
|
55
|
+
// -------------------------------------------------------------------------
|
|
56
|
+
// Pipeline
|
|
57
|
+
// -------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compose multiple enrichers into a single pipeline.
|
|
61
|
+
*
|
|
62
|
+
* Enrichers run sequentially -- each receives the output of the previous.
|
|
63
|
+
* This is intentional: some enrichers depend on previous enrichments
|
|
64
|
+
* (e.g., wishlist may need SKU IDs added by simulation).
|
|
65
|
+
*/
|
|
66
|
+
export function createProductPipeline(
|
|
67
|
+
...enrichers: ProductEnricher[]
|
|
68
|
+
): ProductEnricher {
|
|
69
|
+
return async (products, ctx) => {
|
|
70
|
+
if (!products.length) return products;
|
|
71
|
+
|
|
72
|
+
let result = products;
|
|
73
|
+
for (const enricher of enrichers) {
|
|
74
|
+
try {
|
|
75
|
+
result = await enricher(result, ctx);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(
|
|
78
|
+
`[ProductPipeline] Enricher failed, continuing with unenriched data:`,
|
|
79
|
+
error instanceof Error ? error.message : error,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// -------------------------------------------------------------------------
|
|
88
|
+
// Simulation Enricher
|
|
89
|
+
// -------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
interface SimulationItem {
|
|
92
|
+
itemIndex: number;
|
|
93
|
+
id: string;
|
|
94
|
+
quantity: number;
|
|
95
|
+
seller: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface SimulationResult {
|
|
99
|
+
items: Array<{
|
|
100
|
+
itemIndex: number;
|
|
101
|
+
listPrice: number;
|
|
102
|
+
sellingPrice: number;
|
|
103
|
+
price: number;
|
|
104
|
+
availability: string;
|
|
105
|
+
quantity: number;
|
|
106
|
+
}>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Enrich products with real-time prices from VTEX simulation API.
|
|
111
|
+
*
|
|
112
|
+
* The search index may have stale prices. Simulation returns the
|
|
113
|
+
* actual price the user would pay, accounting for promotions,
|
|
114
|
+
* trade policies, price tables, and regional pricing.
|
|
115
|
+
*
|
|
116
|
+
* @param options.batchSize - Max products per simulation call. @default 50
|
|
117
|
+
*/
|
|
118
|
+
export function withSimulation(options?: {
|
|
119
|
+
batchSize?: number;
|
|
120
|
+
}): ProductEnricher {
|
|
121
|
+
const batchSize = options?.batchSize ?? 50;
|
|
122
|
+
|
|
123
|
+
return async (products, ctx) => {
|
|
124
|
+
const config = getVtexConfig();
|
|
125
|
+
const sc = ctx.salesChannel ?? config.salesChannel ?? "1";
|
|
126
|
+
|
|
127
|
+
const skuItems: SimulationItem[] = [];
|
|
128
|
+
const skuToProductIndex = new Map<
|
|
129
|
+
string,
|
|
130
|
+
{ productIdx: number; offerIdx: number }
|
|
131
|
+
>();
|
|
132
|
+
|
|
133
|
+
for (let pi = 0; pi < products.length; pi++) {
|
|
134
|
+
const product = products[pi];
|
|
135
|
+
const aggOffer = product.offers;
|
|
136
|
+
if (!aggOffer?.offers) continue;
|
|
137
|
+
|
|
138
|
+
for (let oi = 0; oi < aggOffer.offers.length; oi++) {
|
|
139
|
+
const offer = aggOffer.offers[oi];
|
|
140
|
+
const skuId = product.sku ?? product.productID;
|
|
141
|
+
const seller = offer.seller ?? "1";
|
|
142
|
+
|
|
143
|
+
if (skuId) {
|
|
144
|
+
skuItems.push({
|
|
145
|
+
itemIndex: skuItems.length,
|
|
146
|
+
id: skuId,
|
|
147
|
+
quantity: 1,
|
|
148
|
+
seller,
|
|
149
|
+
});
|
|
150
|
+
skuToProductIndex.set(`${skuId}-${seller}`, {
|
|
151
|
+
productIdx: pi,
|
|
152
|
+
offerIdx: oi,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!skuItems.length) return products;
|
|
159
|
+
|
|
160
|
+
const result = [...products];
|
|
161
|
+
const batches: SimulationItem[][] = [];
|
|
162
|
+
for (let i = 0; i < skuItems.length; i += batchSize) {
|
|
163
|
+
batches.push(skuItems.slice(i, i + batchSize));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const batch of batches) {
|
|
167
|
+
try {
|
|
168
|
+
const sim = await vtexFetch<SimulationResult>(
|
|
169
|
+
`/api/checkout/pub/orderForms/simulation?sc=${sc}&RnbBehavior=1`,
|
|
170
|
+
{
|
|
171
|
+
method: "POST",
|
|
172
|
+
body: JSON.stringify({
|
|
173
|
+
items: batch,
|
|
174
|
+
country: config.country ?? "BRA",
|
|
175
|
+
}),
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
for (const simItem of sim.items) {
|
|
180
|
+
const original = batch[simItem.itemIndex];
|
|
181
|
+
if (!original) continue;
|
|
182
|
+
|
|
183
|
+
const key = `${original.id}-${original.seller}`;
|
|
184
|
+
const mapping = skuToProductIndex.get(key);
|
|
185
|
+
if (!mapping) continue;
|
|
186
|
+
|
|
187
|
+
const product = { ...result[mapping.productIdx] };
|
|
188
|
+
const aggOffer = product.offers;
|
|
189
|
+
if (!aggOffer) continue;
|
|
190
|
+
|
|
191
|
+
const offers = [...aggOffer.offers];
|
|
192
|
+
const offer = { ...offers[mapping.offerIdx] };
|
|
193
|
+
|
|
194
|
+
offer.price = simItem.sellingPrice / 100;
|
|
195
|
+
if (simItem.listPrice) {
|
|
196
|
+
(offer as any).priceSpecification = [
|
|
197
|
+
...(Array.isArray((offer as any).priceSpecification)
|
|
198
|
+
? (offer as any).priceSpecification
|
|
199
|
+
: []),
|
|
200
|
+
].map((spec: any) => {
|
|
201
|
+
if (spec?.priceType === "https://schema.org/ListPrice") {
|
|
202
|
+
return { ...spec, price: simItem.listPrice / 100 };
|
|
203
|
+
}
|
|
204
|
+
if (spec?.priceType === "https://schema.org/SalePrice") {
|
|
205
|
+
return { ...spec, price: simItem.sellingPrice / 100 };
|
|
206
|
+
}
|
|
207
|
+
return spec;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
offer.availability =
|
|
211
|
+
simItem.availability === "available"
|
|
212
|
+
? "https://schema.org/InStock"
|
|
213
|
+
: "https://schema.org/OutOfStock";
|
|
214
|
+
|
|
215
|
+
offers[mapping.offerIdx] = offer;
|
|
216
|
+
product.offers = { ...aggOffer, offers };
|
|
217
|
+
result[mapping.productIdx] = product;
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error(
|
|
221
|
+
"[Simulation] Batch failed:",
|
|
222
|
+
error instanceof Error ? error.message : error,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return result;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// -------------------------------------------------------------------------
|
|
232
|
+
// Wishlist Enricher
|
|
233
|
+
// -------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
const WISHLIST_QUERY = `query GetWishlist($shopperId: String!, $name: String!, $from: Int!, $to: Int!) {
|
|
236
|
+
viewList(shopperId: $shopperId, name: $name, from: $from, to: $to)
|
|
237
|
+
@context(provider: "vtex.wish-list@1.x") {
|
|
238
|
+
data {
|
|
239
|
+
id
|
|
240
|
+
productId
|
|
241
|
+
sku
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}`;
|
|
245
|
+
|
|
246
|
+
interface WishlistData {
|
|
247
|
+
viewList: {
|
|
248
|
+
data: Array<{ id: string; productId: string; sku: string }> | null;
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function getCookieValue(cookieHeader: string, name: string): string | null {
|
|
253
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
254
|
+
return match?.[1] ?? null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Enrich products with wishlist status.
|
|
259
|
+
*
|
|
260
|
+
* Reads the user's wishlist and adds `isInWishlist: true` as an
|
|
261
|
+
* additionalProperty on products that are wishlisted.
|
|
262
|
+
*
|
|
263
|
+
* Requires the user to be logged in (reads VtexIdclientAutCookie).
|
|
264
|
+
* For anonymous users, this is a no-op.
|
|
265
|
+
*/
|
|
266
|
+
export function withWishlist(): ProductEnricher {
|
|
267
|
+
return async (products, ctx) => {
|
|
268
|
+
if (!ctx.request) return products;
|
|
269
|
+
|
|
270
|
+
const cookies = ctx.request.headers.get("cookie") ?? "";
|
|
271
|
+
const authCookie = getCookieValue(cookies, VTEX_AUTH_COOKIE);
|
|
272
|
+
if (!authCookie) return products;
|
|
273
|
+
|
|
274
|
+
let email: string | undefined;
|
|
275
|
+
try {
|
|
276
|
+
const parts = authCookie.split(".");
|
|
277
|
+
if (parts.length === 3) {
|
|
278
|
+
const payload = JSON.parse(
|
|
279
|
+
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")),
|
|
280
|
+
);
|
|
281
|
+
email = payload.sub ?? payload.userId;
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
return products;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!email) return products;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const data = await vtexIOGraphQL<WishlistData>(
|
|
291
|
+
{
|
|
292
|
+
query: WISHLIST_QUERY,
|
|
293
|
+
variables: { shopperId: email, name: "Wishlist", from: 0, to: 500 },
|
|
294
|
+
},
|
|
295
|
+
{ Cookie: buildAuthCookieHeader(authCookie, getVtexConfig().account) },
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const wishlistItems = data.viewList?.data ?? [];
|
|
299
|
+
const wishlistSkus = new Set(wishlistItems.map((i) => i.sku));
|
|
300
|
+
const wishlistProductIds = new Set(wishlistItems.map((i) => i.productId));
|
|
301
|
+
|
|
302
|
+
return products.map((product) => {
|
|
303
|
+
const isWishlisted =
|
|
304
|
+
(product.sku && wishlistSkus.has(product.sku)) ||
|
|
305
|
+
(product.productID && wishlistProductIds.has(product.productID));
|
|
306
|
+
|
|
307
|
+
if (!isWishlisted) return product;
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
...product,
|
|
311
|
+
additionalProperty: [
|
|
312
|
+
...(product.additionalProperty ?? []),
|
|
313
|
+
{
|
|
314
|
+
"@type": "PropertyValue" as const,
|
|
315
|
+
name: "isInWishlist",
|
|
316
|
+
value: "true",
|
|
317
|
+
propertyID: "WISHLIST",
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.error(
|
|
324
|
+
"[Wishlist] Failed to fetch wishlist:",
|
|
325
|
+
error instanceof Error ? error.message : error,
|
|
326
|
+
);
|
|
327
|
+
return products;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// -------------------------------------------------------------------------
|
|
333
|
+
// Similars Enricher
|
|
334
|
+
// -------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Enrich products with similar product data from Legacy Catalog API.
|
|
338
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (similarsExt)
|
|
339
|
+
*/
|
|
340
|
+
export function withSimilars(): ProductEnricher {
|
|
341
|
+
return async (products) => {
|
|
342
|
+
return Promise.all(products.map((p) => withIsSimilarTo(p)));
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// -------------------------------------------------------------------------
|
|
347
|
+
// Kit Items Enricher
|
|
348
|
+
// -------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Enrich products with kit item details (isAccessoryOrSparePartFor).
|
|
352
|
+
* Fetches full product data for referenced accessories via Legacy Catalog.
|
|
353
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (kitItemsExt)
|
|
354
|
+
*/
|
|
355
|
+
export function withKitItems(): ProductEnricher {
|
|
356
|
+
return async (products) => {
|
|
357
|
+
const productIDs = new Set<string>();
|
|
358
|
+
|
|
359
|
+
for (const product of products) {
|
|
360
|
+
for (const item of product.isAccessoryOrSparePartFor ?? []) {
|
|
361
|
+
if (item.productID) productIDs.add(item.productID);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!productIDs.size) return products;
|
|
366
|
+
|
|
367
|
+
const config = getVtexConfig();
|
|
368
|
+
const baseUrl = config.publicUrl
|
|
369
|
+
? `https://${config.publicUrl}`
|
|
370
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
371
|
+
|
|
372
|
+
const batches = batch([...productIDs], 10);
|
|
373
|
+
const productsById = new Map<string, ProductLeaf>();
|
|
374
|
+
|
|
375
|
+
for (const ids of batches) {
|
|
376
|
+
try {
|
|
377
|
+
const fq = ids.map((id) => `productId:${id}`);
|
|
378
|
+
const raw = await vtexFetch<LegacyProduct[]>(
|
|
379
|
+
`/api/catalog_system/pub/products/search/?${fq.map((f) => `fq=${f}`).join("&")}&_from=0&_to=${ids.length - 1}`,
|
|
380
|
+
);
|
|
381
|
+
for (const p of raw) {
|
|
382
|
+
const sku = pickSku(p);
|
|
383
|
+
const product = toProduct(p, sku, 0, {
|
|
384
|
+
baseUrl,
|
|
385
|
+
priceCurrency: "BRL",
|
|
386
|
+
});
|
|
387
|
+
for (const leaf of product.isVariantOf?.hasVariant ?? []) {
|
|
388
|
+
productsById.set(leaf.productID, leaf);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} catch (e) {
|
|
392
|
+
console.error(
|
|
393
|
+
"[KitItems] Batch failed:",
|
|
394
|
+
e instanceof Error ? e.message : e,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return products.map((p) => ({
|
|
400
|
+
...p,
|
|
401
|
+
isAccessoryOrSparePartFor: p.isAccessoryOrSparePartFor
|
|
402
|
+
?.map((item) => productsById.get(item.productID))
|
|
403
|
+
.filter((item): item is ProductLeaf => Boolean(item)),
|
|
404
|
+
}));
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// -------------------------------------------------------------------------
|
|
409
|
+
// Variants Enricher
|
|
410
|
+
// -------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Enrich products with full variant data from Legacy Catalog.
|
|
414
|
+
* When products come from IS, they may lack variant details.
|
|
415
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (variantsExt)
|
|
416
|
+
*/
|
|
417
|
+
export function withVariants(): ProductEnricher {
|
|
418
|
+
return async (products) => {
|
|
419
|
+
const productIDs = new Set<string>();
|
|
420
|
+
for (const product of products) {
|
|
421
|
+
if (product.productID) productIDs.add(product.productID);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!productIDs.size) return products;
|
|
425
|
+
|
|
426
|
+
const config = getVtexConfig();
|
|
427
|
+
const baseUrl = config.publicUrl
|
|
428
|
+
? `https://${config.publicUrl}`
|
|
429
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
430
|
+
|
|
431
|
+
const batches = batch([...productIDs], 15);
|
|
432
|
+
const productsById = new Map<string, Product>();
|
|
433
|
+
|
|
434
|
+
for (const ids of batches) {
|
|
435
|
+
try {
|
|
436
|
+
const fq = ids.map((id) => `productId:${id}`);
|
|
437
|
+
const raw = await vtexFetch<LegacyProduct[]>(
|
|
438
|
+
`/api/catalog_system/pub/products/search/?${fq.map((f) => `fq=${f}`).join("&")}&_from=0&_to=${ids.length - 1}`,
|
|
439
|
+
);
|
|
440
|
+
for (const p of raw) {
|
|
441
|
+
const sku = pickSku(p);
|
|
442
|
+
const product = toProduct(p, sku, 0, {
|
|
443
|
+
baseUrl,
|
|
444
|
+
priceCurrency: "BRL",
|
|
445
|
+
});
|
|
446
|
+
productsById.set(product.productID, product);
|
|
447
|
+
}
|
|
448
|
+
} catch (e) {
|
|
449
|
+
console.error(
|
|
450
|
+
"[Variants] Batch failed:",
|
|
451
|
+
e instanceof Error ? e.message : e,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return products.map((p) => ({
|
|
457
|
+
...productsById.get(p.productID),
|
|
458
|
+
...p,
|
|
459
|
+
isVariantOf: productsById.get(p.productID)?.isVariantOf,
|
|
460
|
+
}));
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// -------------------------------------------------------------------------
|
|
465
|
+
// Reviews Enricher
|
|
466
|
+
// -------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Enrich products with reviews and ratings from VTEX Reviews & Ratings app.
|
|
470
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (reviewsExt)
|
|
471
|
+
*/
|
|
472
|
+
export function withReviews(): ProductEnricher {
|
|
473
|
+
return async (products) => {
|
|
474
|
+
const config = getVtexConfig();
|
|
475
|
+
const myHost = `${config.account}.myvtex.com`;
|
|
476
|
+
|
|
477
|
+
const reviewPromises = products.map((product) =>
|
|
478
|
+
vtexFetch<any>(
|
|
479
|
+
`https://${myHost}/reviews-and-ratings/api/reviews?product_id=${product.inProductGroupWithID ?? ""}&from=0&to=10&status=true`,
|
|
480
|
+
).catch(() => ({})),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const ratingPromises = products.map((product) =>
|
|
484
|
+
vtexFetch<any>(
|
|
485
|
+
`https://${myHost}/reviews-and-ratings/api/rating/${product.inProductGroupWithID ?? ""}`,
|
|
486
|
+
).catch(() => ({})),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const [reviews, ratings] = await Promise.all([
|
|
490
|
+
Promise.all(reviewPromises),
|
|
491
|
+
Promise.all(ratingPromises),
|
|
492
|
+
]);
|
|
493
|
+
|
|
494
|
+
return toReview(products, ratings, reviews);
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// -------------------------------------------------------------------------
|
|
499
|
+
// Inventory Enricher
|
|
500
|
+
// -------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Enrich products with inventory/stock data from VTEX Logistics API.
|
|
504
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (inventoryExt)
|
|
505
|
+
*/
|
|
506
|
+
export function withInventory(): ProductEnricher {
|
|
507
|
+
return async (products) => {
|
|
508
|
+
const inventories = await Promise.all(
|
|
509
|
+
products.map((product) => {
|
|
510
|
+
if (!product.sku) return Promise.resolve({});
|
|
511
|
+
return vtexFetch<any>(
|
|
512
|
+
`/api/logistics/pvt/inventory/skus/${product.sku}`,
|
|
513
|
+
).catch(() => ({}));
|
|
514
|
+
}),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
return toInventories(products, inventories);
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// -------------------------------------------------------------------------
|
|
522
|
+
// Brands Enricher
|
|
523
|
+
// -------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Enrich products with brand information from Legacy Catalog.
|
|
527
|
+
* Useful for Intelligent Search results that may lack brand details.
|
|
528
|
+
* Ported from deco-cx/apps vtex/loaders/product/extend.ts (brandsExt)
|
|
529
|
+
*/
|
|
530
|
+
export function withBrands(): ProductEnricher {
|
|
531
|
+
return async (products) => {
|
|
532
|
+
const brands = await listBrands();
|
|
533
|
+
if (!brands?.length) return products;
|
|
534
|
+
|
|
535
|
+
return products.map((p) => {
|
|
536
|
+
const match = brands.find((b) => b["@id"] === p.brand?.["@id"]);
|
|
537
|
+
return match ? { ...p, brand: match } : p;
|
|
538
|
+
});
|
|
539
|
+
};
|
|
540
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SWR in-memory fetch cache for VTEX API responses.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by deco-cx/deco runtime/fetch/fetchCache.ts.
|
|
5
|
+
* Provides in-flight deduplication + stale-while-revalidate for GET requests.
|
|
6
|
+
*
|
|
7
|
+
* Only caches on the server side. Keyed by full URL string.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_ENTRIES = 500;
|
|
11
|
+
|
|
12
|
+
interface CacheEntry {
|
|
13
|
+
body: unknown;
|
|
14
|
+
status: number;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
refreshing: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TTL_BY_STATUS: Record<string, number> = {
|
|
20
|
+
"2xx": 180_000, // 3 min for success
|
|
21
|
+
"404": 10_000, // 10s for not found
|
|
22
|
+
"5xx": 0, // never cache server errors
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function ttlForStatus(status: number): number {
|
|
26
|
+
if (status >= 200 && status < 300) return TTL_BY_STATUS["2xx"];
|
|
27
|
+
if (status === 404) return TTL_BY_STATUS["404"];
|
|
28
|
+
if (status >= 500) return TTL_BY_STATUS["5xx"];
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const store = new Map<string, CacheEntry>();
|
|
33
|
+
const inflight = new Map<string, Promise<CacheEntry>>();
|
|
34
|
+
|
|
35
|
+
function evictIfNeeded() {
|
|
36
|
+
if (store.size <= DEFAULT_MAX_ENTRIES) return;
|
|
37
|
+
const sorted = [...store.entries()].sort(
|
|
38
|
+
(a, b) => a[1].createdAt - b[1].createdAt,
|
|
39
|
+
);
|
|
40
|
+
const toRemove = sorted.slice(0, store.size - DEFAULT_MAX_ENTRIES);
|
|
41
|
+
for (const [key] of toRemove) store.delete(key);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function executeFetch(
|
|
45
|
+
_url: string,
|
|
46
|
+
doFetch: () => Promise<Response>,
|
|
47
|
+
): Promise<CacheEntry> {
|
|
48
|
+
const response = await doFetch();
|
|
49
|
+
if (response.status >= 500) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`fetchWithCache: ${response.status} ${response.statusText}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const body = response.ok ? await response.json() : null;
|
|
55
|
+
return {
|
|
56
|
+
body,
|
|
57
|
+
status: response.status,
|
|
58
|
+
createdAt: Date.now(),
|
|
59
|
+
refreshing: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FetchCacheOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Custom TTL in ms. If provided, overrides status-based TTL.
|
|
66
|
+
*/
|
|
67
|
+
ttl?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wrap a GET fetch call with SWR caching and in-flight dedup.
|
|
72
|
+
*
|
|
73
|
+
* Returns `null` for non-2xx responses that are cached (e.g. 404).
|
|
74
|
+
* 5xx responses throw so the caller can handle them explicitly.
|
|
75
|
+
*
|
|
76
|
+
* @param cacheKey - Unique key (typically the full URL)
|
|
77
|
+
* @param doFetch - The actual fetch call to execute
|
|
78
|
+
* @param opts - Optional overrides
|
|
79
|
+
* @returns Parsed JSON body, or null for cacheable error responses (e.g. 404)
|
|
80
|
+
*/
|
|
81
|
+
export function fetchWithCache<T>(
|
|
82
|
+
cacheKey: string,
|
|
83
|
+
doFetch: () => Promise<Response>,
|
|
84
|
+
opts?: FetchCacheOptions,
|
|
85
|
+
): Promise<T | null> {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const entry = store.get(cacheKey);
|
|
88
|
+
|
|
89
|
+
if (entry) {
|
|
90
|
+
const maxAge = opts?.ttl ?? ttlForStatus(entry.status);
|
|
91
|
+
const isStale = now - entry.createdAt > maxAge;
|
|
92
|
+
|
|
93
|
+
if (!isStale) return Promise.resolve(entry.body as T | null);
|
|
94
|
+
|
|
95
|
+
if (isStale && !entry.refreshing) {
|
|
96
|
+
entry.refreshing = true;
|
|
97
|
+
executeFetch(cacheKey, doFetch)
|
|
98
|
+
.then((fresh) => {
|
|
99
|
+
const ttl = opts?.ttl ?? ttlForStatus(fresh.status);
|
|
100
|
+
// Prevent a transient 4xx from downgrading a previously
|
|
101
|
+
// successful (2xx) cache entry. If the existing entry was
|
|
102
|
+
// already a 4xx (e.g. a stale 404), revalidating with
|
|
103
|
+
// another cacheable 4xx is fine — the 404 TTL is still valid.
|
|
104
|
+
const existingWasSuccess = entry.status >= 200 && entry.status < 300;
|
|
105
|
+
const freshIsError = fresh.status >= 400;
|
|
106
|
+
const wouldDowngrade = existingWasSuccess && freshIsError;
|
|
107
|
+
if (ttl > 0 && !wouldDowngrade) {
|
|
108
|
+
store.set(cacheKey, fresh);
|
|
109
|
+
} else {
|
|
110
|
+
entry.refreshing = false;
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {
|
|
114
|
+
entry.refreshing = false;
|
|
115
|
+
});
|
|
116
|
+
return Promise.resolve(entry.body as T | null);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return Promise.resolve(entry.body as T | null);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const existing = inflight.get(cacheKey);
|
|
123
|
+
if (existing) return existing.then((e) => e.body as T | null);
|
|
124
|
+
|
|
125
|
+
const promise = executeFetch(cacheKey, doFetch)
|
|
126
|
+
.then((fresh) => {
|
|
127
|
+
const ttl = opts?.ttl ?? ttlForStatus(fresh.status);
|
|
128
|
+
if (ttl > 0) {
|
|
129
|
+
store.set(cacheKey, fresh);
|
|
130
|
+
evictIfNeeded();
|
|
131
|
+
}
|
|
132
|
+
return fresh;
|
|
133
|
+
})
|
|
134
|
+
.finally(() => inflight.delete(cacheKey));
|
|
135
|
+
|
|
136
|
+
inflight.set(cacheKey, promise);
|
|
137
|
+
return promise.then((e) => e.body as T | null);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function clearFetchCache() {
|
|
141
|
+
store.clear();
|
|
142
|
+
inflight.clear();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getFetchCacheStats() {
|
|
146
|
+
return {
|
|
147
|
+
entries: store.size,
|
|
148
|
+
inflight: inflight.size,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from "./batch";
|
|
2
|
+
export * from "./cookies";
|
|
3
|
+
export * from "./intelligentSearch";
|
|
4
|
+
export * from "./legacy";
|
|
5
|
+
export * from "./pickAndOmit";
|
|
6
|
+
export * from "./segment";
|
|
7
|
+
export * from "./similars";
|
|
8
|
+
export * from "./slugify";
|
|
9
|
+
export * from "./transform";
|
|
10
|
+
export * from "./types";
|
|
11
|
+
export * from "./proxy";
|
|
12
|
+
export * from "./vtexId";
|
|
13
|
+
export * from "./sitemap";
|
|
14
|
+
export * from "./enrichment";
|
|
15
|
+
export * from "./resourceRange";
|
|
16
|
+
export * from "./fetchCache";
|
|
17
|
+
export * from "./slugCache";
|