@decocms/apps-vtex 8.0.0 → 8.1.0-next.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.
Files changed (133) hide show
  1. package/package.json +7 -52
  2. package/src/index.ts +12 -15
  3. package/src/utils/constants.ts +2 -56
  4. package/src/utils/cookieSanitizer.ts +59 -59
  5. package/src/utils/operationRouter.ts +76 -72
  6. package/src/utils/types.ts +709 -1557
  7. package/src/vtexClient.ts +444 -0
  8. package/src/README.md +0 -6
  9. package/src/__tests__/client-segment-cookie.test.ts +0 -255
  10. package/src/__tests__/client-set-cookie-forward.test.ts +0 -257
  11. package/src/__tests__/schemas.test.ts +0 -71
  12. package/src/actions/address.ts +0 -250
  13. package/src/actions/analytics/sendEvent.ts +0 -86
  14. package/src/actions/auth.ts +0 -333
  15. package/src/actions/checkout.ts +0 -659
  16. package/src/actions/index.ts +0 -11
  17. package/src/actions/masterData.ts +0 -168
  18. package/src/actions/misc.ts +0 -188
  19. package/src/actions/newsletter.ts +0 -105
  20. package/src/actions/orders.ts +0 -34
  21. package/src/actions/profile.ts +0 -201
  22. package/src/actions/session.ts +0 -85
  23. package/src/actions/trigger.ts +0 -43
  24. package/src/actions/wishlist.ts +0 -114
  25. package/src/client.ts +0 -667
  26. package/src/commerceLoaders.ts +0 -267
  27. package/src/hooks/__tests__/createCart.render.test.ts +0 -114
  28. package/src/hooks/__tests__/createCart.test.ts +0 -92
  29. package/src/hooks/__tests__/createUseCart.test.ts +0 -184
  30. package/src/hooks/__tests__/createUseUser.test.ts +0 -48
  31. package/src/hooks/__tests__/createUseWishlist.test.ts +0 -87
  32. package/src/hooks/cartQuery.ts +0 -204
  33. package/src/hooks/createCart.ts +0 -427
  34. package/src/hooks/createUseCart.ts +0 -360
  35. package/src/hooks/createUseUser.ts +0 -153
  36. package/src/hooks/createUseWishlist.ts +0 -242
  37. package/src/hooks/index.ts +0 -25
  38. package/src/hooks/useAutocomplete.ts +0 -83
  39. package/src/hooks/useCart.ts +0 -243
  40. package/src/hooks/useUser.ts +0 -78
  41. package/src/hooks/useWishlist.ts +0 -119
  42. package/src/invoke.ts +0 -235
  43. package/src/loaders/ProductDetailsPage.ts +0 -1
  44. package/src/loaders/ProductList.ts +0 -1
  45. package/src/loaders/ProductListingPage.ts +0 -1
  46. package/src/loaders/address.ts +0 -115
  47. package/src/loaders/autocomplete.ts +0 -58
  48. package/src/loaders/brands.ts +0 -44
  49. package/src/loaders/cart/attachments.ts +0 -53
  50. package/src/loaders/cart/full.ts +0 -67
  51. package/src/loaders/cart/gifts.ts +0 -43
  52. package/src/loaders/cart/orderFormId.ts +0 -31
  53. package/src/loaders/cart/shipping.test.ts +0 -125
  54. package/src/loaders/cart/shipping.ts +0 -136
  55. package/src/loaders/cart/summary.ts +0 -36
  56. package/src/loaders/cart.ts +0 -52
  57. package/src/loaders/catalog.ts +0 -163
  58. package/src/loaders/collections.ts +0 -55
  59. package/src/loaders/index.ts +0 -20
  60. package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +0 -51
  61. package/src/loaders/intelligentSearch/productDetailsPage.ts +0 -113
  62. package/src/loaders/intelligentSearch/productList.ts +0 -154
  63. package/src/loaders/intelligentSearch/productListingPage.ts +0 -526
  64. package/src/loaders/intelligentSearch/suggestions.ts +0 -46
  65. package/src/loaders/legacy/productDetailsPage.ts +0 -1
  66. package/src/loaders/legacy/productList.ts +0 -1
  67. package/src/loaders/legacy/relatedProductsLoader.ts +0 -81
  68. package/src/loaders/legacy.ts +0 -635
  69. package/src/loaders/logistics.ts +0 -111
  70. package/src/loaders/minicart.ts +0 -78
  71. package/src/loaders/navbar.ts +0 -26
  72. package/src/loaders/orders.ts +0 -92
  73. package/src/loaders/pageType.ts +0 -68
  74. package/src/loaders/paths/PDPDefaultPath.ts +0 -51
  75. package/src/loaders/payment.ts +0 -97
  76. package/src/loaders/productListFull.ts +0 -159
  77. package/src/loaders/profile.ts +0 -138
  78. package/src/loaders/promotion.ts +0 -30
  79. package/src/loaders/search.ts +0 -124
  80. package/src/loaders/session.ts +0 -83
  81. package/src/loaders/user.ts +0 -87
  82. package/src/loaders/wishlist.ts +0 -89
  83. package/src/loaders/wishlistProducts.ts +0 -69
  84. package/src/loaders/workflow/products.ts +0 -64
  85. package/src/loaders/workflow.ts +0 -316
  86. package/src/logo.png +0 -0
  87. package/src/manifest.gen.ts +0 -89
  88. package/src/middleware.ts +0 -240
  89. package/src/mod.ts +0 -157
  90. package/src/registry.ts +0 -9
  91. package/src/schemas.gen.ts +0 -3191
  92. package/src/schemas.ts +0 -30
  93. package/src/types.ts +0 -248
  94. package/src/utils/__tests__/cartProjection.test.ts +0 -118
  95. package/src/utils/__tests__/cookieSanitizer.test.ts +0 -162
  96. package/src/utils/__tests__/fetch.test.ts +0 -80
  97. package/src/utils/__tests__/fetchCache.test.ts +0 -155
  98. package/src/utils/__tests__/instrumentedFetch.test.ts +0 -158
  99. package/src/utils/__tests__/intelligentSearch.test.ts +0 -88
  100. package/src/utils/__tests__/minicart.test.ts +0 -184
  101. package/src/utils/__tests__/operationRouter.test.ts +0 -227
  102. package/src/utils/__tests__/resilience.test.ts +0 -205
  103. package/src/utils/__tests__/resourceRange.test.ts +0 -32
  104. package/src/utils/__tests__/simulationCache.test.ts +0 -40
  105. package/src/utils/__tests__/sitemap.test.ts +0 -185
  106. package/src/utils/__tests__/slugify.test.ts +0 -31
  107. package/src/utils/__tests__/transform.test.ts +0 -769
  108. package/src/utils/accountLoaders.ts +0 -203
  109. package/src/utils/authHelpers.ts +0 -85
  110. package/src/utils/batch.ts +0 -18
  111. package/src/utils/cartProjection.ts +0 -96
  112. package/src/utils/cookies.ts +0 -167
  113. package/src/utils/enrichment.ts +0 -560
  114. package/src/utils/fetch.ts +0 -107
  115. package/src/utils/fetchCache.ts +0 -199
  116. package/src/utils/index.ts +0 -19
  117. package/src/utils/instrumentedFetch.ts +0 -97
  118. package/src/utils/intelligentSearch.ts +0 -96
  119. package/src/utils/legacy.ts +0 -139
  120. package/src/utils/minicart.ts +0 -139
  121. package/src/utils/pickAndOmit.ts +0 -25
  122. package/src/utils/proxy.ts +0 -435
  123. package/src/utils/resilience.ts +0 -352
  124. package/src/utils/resourceRange.ts +0 -10
  125. package/src/utils/segment.ts +0 -152
  126. package/src/utils/similars.ts +0 -37
  127. package/src/utils/simulationCache.ts +0 -73
  128. package/src/utils/sitemap.ts +0 -282
  129. package/src/utils/slugCache.ts +0 -28
  130. package/src/utils/slugify.ts +0 -13
  131. package/src/utils/transform.ts +0 -1522
  132. package/src/utils/vtexId.ts +0 -138
  133. package/tsconfig.json +0 -7
@@ -1,1522 +0,0 @@
1
- import type {
2
- AggregateOffer,
3
- Brand,
4
- BreadcrumbList,
5
- DayOfWeek,
6
- Filter,
7
- FilterToggleValue,
8
- ItemAvailability,
9
- Offer,
10
- OpeningHoursSpecification,
11
- PageType,
12
- Place,
13
- PostalAddress,
14
- PriceComponentTypeEnumeration,
15
- PriceTypeEnumeration,
16
- Product,
17
- ProductDetailsPage,
18
- ProductGroup,
19
- PropertyValue,
20
- SiteNavigationElement,
21
- UnitPriceSpecification,
22
- } from "@decocms/apps-commerce/types";
23
- import { DEFAULT_IMAGE } from "@decocms/apps-commerce/utils/constants";
24
- import { formatRange } from "@decocms/apps-commerce/utils/filters";
25
- import { pick } from "./pickAndOmit";
26
- import { slugify } from "./slugify";
27
- import type {
28
- Address,
29
- Brand as BrandVTEX,
30
- Category,
31
- FacetValueBoolean,
32
- FacetValueRange,
33
- Facet as FacetVTEX,
34
- LegacyFacet,
35
- LegacyProduct,
36
- LegacyProduct as LegacyProductVTEX,
37
- LegacyItem as LegacySkuVTEX,
38
- Maybe,
39
- OrderForm,
40
- PageType as PageTypeVTEX,
41
- PickupHolidays,
42
- PickupPoint,
43
- ProductInventoryData,
44
- ProductRating,
45
- ProductReviewData,
46
- Product as ProductVTEX,
47
- SelectedFacet,
48
- Seller as SellerVTEX,
49
- Item as SkuVTEX,
50
- Teasers,
51
- } from "./types";
52
-
53
- interface PickupPointVCS {
54
- name?: string;
55
- address?: {
56
- country?: { acronym?: string };
57
- location?: { latitude?: number; longitude?: number };
58
- city?: string;
59
- state?: string;
60
- postalCode?: string;
61
- street?: string;
62
- };
63
- pickupHolidays?: any[];
64
- businessHours?: any[];
65
- isActive?: boolean;
66
- id?: string;
67
- [key: string]: unknown;
68
- }
69
-
70
- const DEFAULT_CATEGORY_SEPARATOR = ">";
71
-
72
- export const SCHEMA_LIST_PRICE: PriceTypeEnumeration = "https://schema.org/ListPrice";
73
- export const SCHEMA_SALE_PRICE: PriceTypeEnumeration = "https://schema.org/SalePrice";
74
- export const SCHEMA_SRP: PriceTypeEnumeration = "https://schema.org/SRP";
75
- export const SCHEMA_INSTALLMENT: PriceComponentTypeEnumeration = "https://schema.org/Installment";
76
- export const SCHEMA_IN_STOCK: ItemAvailability = "https://schema.org/InStock";
77
- export const SCHEMA_OUT_OF_STOCK: ItemAvailability = "https://schema.org/OutOfStock";
78
-
79
- const isLegacySku = (sku: LegacySkuVTEX | SkuVTEX): sku is LegacySkuVTEX =>
80
- typeof (sku as LegacySkuVTEX).variations?.[0] === "string" || !!(sku as LegacySkuVTEX).Videos;
81
-
82
- const isLegacyProduct = (product: ProductVTEX | LegacyProductVTEX): product is LegacyProductVTEX =>
83
- product.origin !== "intelligent-search";
84
-
85
- const getProductGroupURL = (origin: string, { linkText }: { linkText: string }) =>
86
- new URL(`/${linkText}/p`, origin);
87
-
88
- const getProductURL = (origin: string, product: { linkText: string }, skuId?: string) => {
89
- const canonicalUrl = getProductGroupURL(origin, product);
90
-
91
- if (skuId) {
92
- canonicalUrl.searchParams.set("skuId", skuId);
93
- }
94
-
95
- return canonicalUrl;
96
- };
97
-
98
- const nonEmptyArray = <T>(array: T[] | null | undefined) =>
99
- Array.isArray(array) && array.length > 0 ? array : null;
100
-
101
- interface ProductOptions {
102
- baseUrl: string;
103
- /** Price coded currency, e.g.: USD, BRL */
104
- priceCurrency: string;
105
- imagesByKey?: Map<string, string>;
106
- /** Original attributes to be included in the transformed product */
107
- includeOriginalAttributes?: string[];
108
- /** Use lean toProductVariant for hasVariant[] instead of full toProduct at level=1 */
109
- leanVariants?: boolean;
110
- /** Property names to keep on lean variant additionalProperty. Defaults to VARIANT_PROPERTY_NAMES. */
111
- variantPropertyNames?: Set<string>;
112
- /** When leanVariants is true, still include image[0] on each variant entry. Default true. */
113
- variantIncludeImage?: boolean;
114
- /** When leanVariants is true, still include inventoryLevel on each variant offer. Default true. */
115
- variantIncludeInventory?: boolean;
116
- }
117
-
118
- /** Returns first available sku */
119
- const findFirstAvailable = (items: Array<LegacySkuVTEX | SkuVTEX>) =>
120
- items?.find((item) =>
121
- Boolean(item?.sellers?.find((s) => s.commertialOffer?.AvailableQuantity > 0)),
122
- );
123
-
124
- export const pickSku = <T extends ProductVTEX | LegacyProductVTEX>(
125
- product: T,
126
- maybeSkuId?: string,
127
- ): T["items"][number] => {
128
- const skuId = maybeSkuId ?? findFirstAvailable(product.items)?.itemId ?? product.items[0]?.itemId;
129
- for (const item of product.items) {
130
- if (item.itemId === skuId) {
131
- return item;
132
- }
133
- }
134
-
135
- return product.items[0];
136
- };
137
-
138
- const toAccessoryOrSparePartFor = <T extends ProductVTEX | LegacyProductVTEX>(
139
- sku: T["items"][number],
140
- kitItems: T[],
141
- options: ProductOptions,
142
- ) => {
143
- const productBySkuId = kitItems.reduce((map, product) => {
144
- product.items.forEach((item) => map.set(item.itemId, product));
145
-
146
- return map;
147
- }, new Map<string, T>());
148
-
149
- return sku.kitItems
150
- ?.map(({ itemId }) => {
151
- const product = productBySkuId.get(itemId);
152
-
153
- /** Sometimes VTEX does not return what I've asked for */
154
- if (!product) return;
155
-
156
- const sku = pickSku(product, itemId);
157
-
158
- return toProduct(product, sku, 0, options);
159
- })
160
- .filter((p): p is Product => typeof p !== "undefined");
161
- };
162
-
163
- export const forceHttpsOnAssets = (orderForm: OrderForm) => {
164
- orderForm.items.forEach((item) => {
165
- if (item.imageUrl) {
166
- item.imageUrl = item.imageUrl.startsWith("http://")
167
- ? item.imageUrl.replace("http://", "https://")
168
- : item.imageUrl;
169
- }
170
- });
171
- return orderForm;
172
- };
173
-
174
- export const toProductPage = <T extends ProductVTEX | LegacyProductVTEX>(
175
- product: T,
176
- sku: T["items"][number],
177
- kitItems: T[],
178
- options: ProductOptions,
179
- ): Omit<ProductDetailsPage, "seo"> => {
180
- const partialProduct = toProduct(product, sku, 0, options);
181
- // This is deprecated. Compose this loader at loaders > product > extension > detailsPage.ts
182
- const isAccessoryOrSparePartFor = toAccessoryOrSparePartFor(sku, kitItems, options);
183
-
184
- return {
185
- "@type": "ProductDetailsPage",
186
- breadcrumbList: toBreadcrumbList(product, options),
187
- product: { ...partialProduct, isAccessoryOrSparePartFor },
188
- };
189
- };
190
-
191
- export const inStock = (offer: Offer) => offer.availability === SCHEMA_IN_STOCK;
192
-
193
- // Smallest Available Spot Price First
194
- export const bestOfferFirst = (a: Offer, b: Offer) => {
195
- if (inStock(a) && !inStock(b)) {
196
- return -1;
197
- }
198
-
199
- if (!inStock(a) && inStock(b)) {
200
- return 1;
201
- }
202
-
203
- return a.price - b.price;
204
- };
205
-
206
- const getHighPriceIndex = (offers: Offer[]) => {
207
- let it = offers.length - 1;
208
- for (; it > 0 && !inStock(offers[it]); it--);
209
- return it;
210
- };
211
-
212
- const splitCategory = (firstCategory: string) => firstCategory.split("/").filter(Boolean);
213
-
214
- const toAdditionalPropertyCategories = <P extends LegacyProductVTEX | ProductVTEX>(
215
- product: P,
216
- ): Product["additionalProperty"] => {
217
- const categories = new Set<string>();
218
- const categoryIds = new Set<string>();
219
-
220
- product.categories.forEach((productCategory, i) => {
221
- const category = splitCategory(productCategory);
222
- const categoryId = splitCategory(product.categoriesIds[i]);
223
-
224
- category.forEach((splitCategoryItem, j) => {
225
- categories.add(splitCategoryItem);
226
- categoryIds.add(categoryId[j]);
227
- });
228
- });
229
-
230
- const categoriesArray = Array.from(categories);
231
- const categoryIdsArray = Array.from(categoryIds);
232
-
233
- return categoriesArray.map((category, index) =>
234
- toAdditionalPropertyCategory({
235
- propertyID: categoryIdsArray[index],
236
- value: category || "",
237
- }),
238
- );
239
- };
240
-
241
- export const toAdditionalPropertyCategory = ({
242
- propertyID,
243
- value,
244
- }: {
245
- propertyID: string;
246
- value: string;
247
- }): PropertyValue => ({
248
- "@type": "PropertyValue" as const,
249
- name: "category",
250
- propertyID,
251
- value,
252
- });
253
-
254
- const toAdditionalPropertyClusters = <P extends LegacyProductVTEX | ProductVTEX>(
255
- product: P,
256
- ): Product["additionalProperty"] => {
257
- const mapEntriesToIdName = ([id, name]: [string, unknown]) => ({
258
- id,
259
- name: name as string,
260
- });
261
-
262
- const allClusters = isLegacyProduct(product)
263
- ? Object.entries(product.productClusters).map(mapEntriesToIdName)
264
- : product.productClusters;
265
-
266
- const highlightsSet = isLegacyProduct(product)
267
- ? new Set(Object.keys(product.clusterHighlights))
268
- : new Set(product.clusterHighlights.map(({ id }) => id));
269
-
270
- return allClusters.map((cluster) =>
271
- toAdditionalPropertyCluster(
272
- {
273
- propertyID: cluster.id,
274
- value: cluster.name || "",
275
- },
276
- highlightsSet,
277
- ),
278
- );
279
- };
280
-
281
- export const toAdditionalPropertyCluster = (
282
- { propertyID, value }: { propertyID: string; value: string },
283
- highlights?: Set<string>,
284
- ): PropertyValue => ({
285
- "@type": "PropertyValue",
286
- name: "cluster",
287
- value,
288
- propertyID,
289
- description: highlights?.has(propertyID) ? "highlight" : undefined,
290
- });
291
-
292
- const toAdditionalPropertyReferenceIds = (
293
- referenceId: Array<{ Key: string; Value: string }>,
294
- ): Product["additionalProperty"] => {
295
- return referenceId.map(({ Key, Value }) =>
296
- toAdditionalPropertyReferenceId({ name: Key, value: Value }),
297
- );
298
- };
299
-
300
- export const toAdditionalPropertyReferenceId = ({
301
- name,
302
- value,
303
- }: {
304
- name: string;
305
- value: string;
306
- }): PropertyValue => ({
307
- "@type": "PropertyValue",
308
- name,
309
- value,
310
- valueReference: "ReferenceID",
311
- });
312
-
313
- const getImageKey = (src = "") => {
314
- return src;
315
-
316
- // TODO: figure out how we can improve this
317
- // const match = new URLPattern({
318
- // pathname: "/arquivos/ids/:skuId/:imageId",
319
- // }).exec(src);
320
-
321
- // if (match == null) {
322
- // return src;
323
- // }
324
-
325
- // return `${match.pathname.groups.imageId}${match.search.input}`;
326
- };
327
-
328
- export const aggregateOffers = (
329
- offers: Offer[],
330
- priceCurrency?: string,
331
- ): AggregateOffer | undefined => {
332
- const sorted = offers.sort(bestOfferFirst);
333
-
334
- if (sorted.length === 0) return;
335
-
336
- const highPriceIndex = getHighPriceIndex(sorted);
337
- const lowPriceIndex = 0;
338
-
339
- return {
340
- "@type": "AggregateOffer",
341
- priceCurrency,
342
- highPrice: sorted[highPriceIndex]?.price ?? null,
343
- lowPrice: sorted[lowPriceIndex]?.price ?? null,
344
- offerCount: sorted.length,
345
- offers: sorted,
346
- };
347
- };
348
-
349
- export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
350
- product: P,
351
- sku: P["items"][number],
352
- level = 0, // prevent inifinte loop while self referencing the product
353
- options: ProductOptions,
354
- ): Product => {
355
- const { baseUrl, priceCurrency } = options;
356
- const {
357
- brand,
358
- brandId,
359
- brandImageUrl,
360
- productId,
361
- productReference,
362
- description,
363
- releaseDate,
364
- items,
365
- } = product;
366
- const { name, ean, itemId: skuId, referenceId = [], kitItems, estimatedDateArrival } = sku;
367
-
368
- const videos = isLegacySku(sku) ? sku.Videos : sku.videos;
369
- const nonEmptyVideos = nonEmptyArray(videos);
370
- const imagesByKey =
371
- options.imagesByKey ??
372
- items
373
- .flatMap((i) => i.images)
374
- .reduce((map, img) => {
375
- img?.imageUrl && map.set(getImageKey(img.imageUrl), img.imageUrl);
376
- return map;
377
- }, new Map<string, string>());
378
-
379
- const groupAdditionalProperty = isLegacyProduct(product)
380
- ? legacyToProductGroupAdditionalProperties(product)
381
- : toProductGroupAdditionalProperties(product);
382
- const originalAttributesAdditionalProperties = toOriginalAttributesAdditionalProperties(
383
- options.includeOriginalAttributes,
384
- product,
385
- );
386
- const specificationsAdditionalProperty = isLegacySku(sku)
387
- ? toAdditionalPropertiesLegacy(sku)
388
- : toAdditionalProperties(sku);
389
- const referenceIdAdditionalProperty = toAdditionalPropertyReferenceIds(referenceId);
390
- const images = nonEmptyArray(sku.images);
391
- const offers = (sku.sellers ?? []).map(isLegacyProduct(product) ? toOfferLegacy : toOffer);
392
-
393
- const variantOptions =
394
- imagesByKey !== options.imagesByKey ? { ...options, imagesByKey } : options;
395
- const isVariantOf =
396
- level < 1
397
- ? ({
398
- "@type": "ProductGroup",
399
- productGroupID: productId,
400
- hasVariant: options.leanVariants
401
- ? items.map((sku) => toProductVariant(product, sku, variantOptions))
402
- : items.map((sku) => toProduct(product, sku, 1, variantOptions)),
403
- url: getProductGroupURL(baseUrl, product).href,
404
- name: product.productName,
405
- additionalProperty: [
406
- ...groupAdditionalProperty,
407
- ...originalAttributesAdditionalProperties,
408
- ],
409
- model: productReference,
410
- } satisfies ProductGroup)
411
- : undefined;
412
-
413
- const finalImages = images?.map(({ imageUrl, imageText, imageLabel }) => {
414
- const url = imagesByKey.get(getImageKey(imageUrl)) ?? imageUrl;
415
- const alternateName = imageText || imageLabel || "";
416
- const name = imageLabel || "";
417
- const encodingFormat = "image";
418
-
419
- return {
420
- "@type": "ImageObject" as const,
421
- alternateName,
422
- url,
423
- name,
424
- encodingFormat,
425
- };
426
- }) ?? [DEFAULT_IMAGE];
427
-
428
- const finalVideos = nonEmptyVideos?.map((video) => {
429
- const url = video;
430
- const alternateName = "Product video";
431
- const name = "Product video";
432
- const encodingFormat = "video";
433
- return {
434
- "@type": "VideoObject" as const,
435
- alternateName,
436
- contentUrl: url,
437
- name,
438
- encodingFormat,
439
- };
440
- });
441
-
442
- // From schema.org: A category for the item. Greater signs or slashes can be used to informally indicate a category hierarchy
443
- const categoriesString = splitCategory(product.categories[0]).join(DEFAULT_CATEGORY_SEPARATOR);
444
-
445
- const categoryAdditionalProperties = toAdditionalPropertyCategories(product);
446
- const clusterAdditionalProperties = toAdditionalPropertyClusters(product);
447
-
448
- const additionalProperty: PropertyValue[] = [...specificationsAdditionalProperty];
449
- if (categoryAdditionalProperties) {
450
- additionalProperty.push(...categoryAdditionalProperties);
451
- }
452
- if (clusterAdditionalProperties) {
453
- additionalProperty.push(...clusterAdditionalProperties);
454
- }
455
- if (referenceIdAdditionalProperty) {
456
- additionalProperty.push(...referenceIdAdditionalProperty);
457
- }
458
-
459
- estimatedDateArrival &&
460
- additionalProperty.push({
461
- "@type": "PropertyValue",
462
- name: "Estimated Date Arrival",
463
- value: estimatedDateArrival,
464
- });
465
-
466
- if (sku.modalType) {
467
- additionalProperty.push({
468
- "@type": "PropertyValue",
469
- name: "Modal Type",
470
- value: sku.modalType,
471
- });
472
- }
473
-
474
- return {
475
- "@type": "Product",
476
- category: categoriesString,
477
- productID: skuId,
478
- url: getProductURL(baseUrl, product, sku.itemId).href,
479
- name,
480
- alternateName: sku.complementName,
481
- description,
482
- brand: {
483
- "@type": "Brand",
484
- "@id": brandId?.toString(),
485
- name: brand,
486
- logo: brandImageUrl,
487
- },
488
- isAccessoryOrSparePartFor: kitItems?.map(({ itemId }) => ({
489
- "@type": "Product",
490
- productID: itemId,
491
- sku: itemId,
492
- })),
493
- inProductGroupWithID: productId,
494
- sku: skuId,
495
- gtin: ean,
496
- releaseDate,
497
- additionalProperty,
498
- isVariantOf,
499
- image: finalImages,
500
- video: finalVideos,
501
- offers: aggregateOffers(offers, priceCurrency),
502
- };
503
- };
504
-
505
- /**
506
- * Determines if an installment has no interest by checking if
507
- * billingDuration * billingIncrement ≈ total price (within 1 cent tolerance).
508
- */
509
- const isNoInterest = (spec: UnitPriceSpecification): boolean => {
510
- if (spec.billingDuration == null || spec.billingIncrement == null || spec.price == null) {
511
- return false;
512
- }
513
- return Math.abs(spec.billingDuration * spec.billingIncrement - spec.price) < 0.01;
514
- };
515
-
516
- /**
517
- * Build a lean offer for shelf display. Keeps only:
518
- * - ListPrice, SalePrice, SRP price types
519
- * - PIX installment (name?.toUpperCase() === "PIX")
520
- * - Best no-interest installment (highest billingDuration)
521
- * Drops: inventoryLevel, giftSkuIds, priceValidUntil
522
- */
523
- const buildOfferShelf = (offer: Offer): Offer => {
524
- const leanSpecs: UnitPriceSpecification[] = [];
525
-
526
- let bestNoInterest: UnitPriceSpecification | null = null;
527
-
528
- for (const spec of offer.priceSpecification ?? []) {
529
- // Keep base price types
530
- if (
531
- spec.priceType === SCHEMA_LIST_PRICE ||
532
- spec.priceType === SCHEMA_SALE_PRICE ||
533
- spec.priceType === SCHEMA_SRP
534
- ) {
535
- if (spec.priceComponentType !== SCHEMA_INSTALLMENT) {
536
- leanSpecs.push(spec);
537
- continue;
538
- }
539
- }
540
-
541
- // Keep PIX installment
542
- if (spec.priceComponentType === SCHEMA_INSTALLMENT && spec.name?.toUpperCase() === "PIX") {
543
- leanSpecs.push(spec);
544
- continue;
545
- }
546
-
547
- // Track best no-interest installment (highest billingDuration)
548
- if (
549
- spec.priceComponentType === SCHEMA_INSTALLMENT &&
550
- isNoInterest(spec) &&
551
- (bestNoInterest == null ||
552
- (spec.billingDuration ?? 0) > (bestNoInterest.billingDuration ?? 0))
553
- ) {
554
- bestNoInterest = spec;
555
- }
556
- }
557
-
558
- if (bestNoInterest) {
559
- leanSpecs.push(bestNoInterest);
560
- }
561
-
562
- return {
563
- "@type": "Offer",
564
- identifier: offer.identifier,
565
- price: offer.price,
566
- seller: offer.seller,
567
- sellerName: offer.sellerName,
568
- teasers: offer.teasers,
569
- priceSpecification: leanSpecs,
570
- availability: offer.availability,
571
- inventoryLevel: { value: 0 },
572
- };
573
- };
574
-
575
- /** Property names commonly used by ProductCard/Shelf components */
576
- const SHELF_PROPERTY_NAMES = new Set([
577
- "category",
578
- "cluster",
579
- "Campanha",
580
- "Cor",
581
- "Tamanho",
582
- "Voltagem",
583
- "sellerId",
584
- ]);
585
-
586
- /**
587
- * Lean product transform for shelf/card display. Same signature as toProduct().
588
- *
589
- * Differences from toProduct():
590
- * - Images: capped at 2 per SKU (front + back)
591
- * - Offers: best seller only (in-stock first, then cheapest), stripped installments (keeps ListPrice, SalePrice, SRP, PIX, best no-interest)
592
- * - isVariantOf: single in-stock variant at level 0
593
- * - additionalProperty: filtered to known-used property names
594
- * - Drops: description, video, isAccessoryOrSparePartFor, alternateName, gtin, releaseDate, model
595
- */
596
- export const toProductShelf = <P extends LegacyProductVTEX | ProductVTEX>(
597
- product: P,
598
- sku: P["items"][number],
599
- level = 0,
600
- options: ProductOptions,
601
- ): Product => {
602
- const { baseUrl, priceCurrency } = options;
603
- const { productId, items } = product;
604
- const { name, itemId: skuId } = sku;
605
-
606
- // Images: cap at 2
607
- const rawImages = nonEmptyArray(sku.images);
608
- const mappedImages = (rawImages ?? []).slice(0, 2).map(({ imageUrl, imageText, imageLabel }) => ({
609
- "@type": "ImageObject" as const,
610
- alternateName: imageText || imageLabel || "",
611
- url: imageUrl,
612
- name: imageLabel || "",
613
- encodingFormat: "image",
614
- }));
615
- const finalImages = mappedImages.length > 0 ? mappedImages : [DEFAULT_IMAGE];
616
-
617
- // Offers: best seller (in-stock first, then cheapest), lean.
618
- // Must consider ALL sellers so marketplace products where sellers[0]
619
- // is OOS but another seller has stock still show as available.
620
- const offerConverter = isLegacyProduct(product) ? toOfferLegacy : toOffer;
621
- const allOffers = (sku.sellers ?? []).map(offerConverter).sort(bestOfferFirst);
622
- const bestOffer = allOffers[0];
623
- const leanOffers = bestOffer ? [buildOfferShelf(bestOffer)] : [];
624
-
625
- // additionalProperty: filter to known-used names
626
- const specificationsAdditionalProperty = isLegacySku(sku)
627
- ? toAdditionalPropertiesLegacy(sku)
628
- : toAdditionalProperties(sku);
629
- const categoryAdditionalProperties = toAdditionalPropertyCategories(product) ?? [];
630
- const clusterAdditionalProperties = toAdditionalPropertyClusters(product) ?? [];
631
-
632
- const additionalProperty = [
633
- ...specificationsAdditionalProperty,
634
- ...categoryAdditionalProperties,
635
- ...clusterAdditionalProperties,
636
- ].filter((prop) => SHELF_PROPERTY_NAMES.has(prop.name ?? ""));
637
-
638
- // Product-group specs (from specificationGroups) filtered to shelf-safe names.
639
- // Launch markers like "Campanha" live here, NOT in sku.variations, so they only
640
- // reach the card through the ProductGroup's additionalProperty (mirrors toProduct).
641
- const groupAdditionalProperty = (
642
- isLegacyProduct(product)
643
- ? legacyToProductGroupAdditionalProperties(product)
644
- : toProductGroupAdditionalProperties(product)
645
- ).filter((prop) => SHELF_PROPERTY_NAMES.has(prop.name ?? ""));
646
-
647
- // isVariantOf: single in-stock variant at level 0
648
- const isVariantOf =
649
- level < 1
650
- ? (() => {
651
- const inStockSku = findFirstAvailable(items) ?? items[0];
652
- const singleVariant = inStockSku ? [toProductShelf(product, inStockSku, 1, options)] : [];
653
- return {
654
- "@type": "ProductGroup" as const,
655
- productGroupID: productId,
656
- hasVariant: singleVariant,
657
- url: getProductGroupURL(baseUrl, product).href,
658
- name: product.productName,
659
- // Carry the shelf-safe group specs (incl. "Campanha") so ProductCard
660
- // flags that read isVariantOf.additionalProperty (e.g. ReleaseFlag ->
661
- // "Lançamento") still fire on shelf/carousel cards.
662
- additionalProperty: groupAdditionalProperty,
663
- } satisfies ProductGroup;
664
- })()
665
- : undefined;
666
-
667
- const categoriesString = splitCategory(product.categories[0]).join(DEFAULT_CATEGORY_SEPARATOR);
668
-
669
- return {
670
- "@type": "Product",
671
- category: categoriesString,
672
- productID: skuId,
673
- url: getProductURL(baseUrl, product, sku.itemId).href,
674
- name,
675
- brand: { "@type": "Brand", name: product.brand },
676
- inProductGroupWithID: productId,
677
- sku: skuId,
678
- image: finalImages,
679
- offers: aggregateOffers(leanOffers, priceCurrency),
680
- isVariantOf,
681
- additionalProperty,
682
- };
683
- };
684
-
685
- /** Property names that differentiate SKU variants (used by variant selectors) */
686
- const VARIANT_PROPERTY_NAMES = new Set(["Cor", "Voltagem", "Tamanho"]);
687
-
688
- /**
689
- * Build a minimal offer for variant display. Keeps only availability and seller.
690
- * No priceSpecification, no teasers. inventoryLevel is preserved from the real
691
- * offer unless `includeInventory` is false (variant selectors rely on it to
692
- * decide stock state per SKU — dropping it hard-zeroes every variant).
693
- */
694
- const buildOfferVariant = (offer: Offer, includeInventory: boolean): Offer => ({
695
- "@type": "Offer",
696
- identifier: offer.identifier,
697
- price: offer.price,
698
- seller: offer.seller,
699
- availability: offer.availability,
700
- priceSpecification: [],
701
- inventoryLevel: includeInventory ? offer.inventoryLevel : { value: 0 },
702
- });
703
-
704
- /**
705
- * Minimal product transform for variant entries inside isVariantOf.hasVariant[].
706
- *
707
- * Keeps only what variant selectors need:
708
- * - url, productID, sku, name, inProductGroupWithID
709
- * - additionalProperty filtered to variant-differentiating props (Cor, Voltagem, Tamanho)
710
- * - image[0] (when variantIncludeImage is not false) — selectors render thumbnails
711
- * - offers with availability + seller + real inventoryLevel (when variantIncludeInventory
712
- * is not false) — selectors decide stock state per SKU from inventoryLevel.value
713
- *
714
- * Drops: description, video, brand, category, gtin, releaseDate,
715
- * alternateName, isAccessoryOrSparePartFor, isVariantOf
716
- */
717
- export const toProductVariant = <P extends LegacyProductVTEX | ProductVTEX>(
718
- product: P,
719
- sku: P["items"][number],
720
- options: ProductOptions,
721
- ): Product => {
722
- const { baseUrl, priceCurrency } = options;
723
- const { productId, items } = product;
724
- const { name, itemId: skuId } = sku;
725
- const variantProps = options.variantPropertyNames ?? VARIANT_PROPERTY_NAMES;
726
- const includeImage = options.variantIncludeImage !== false;
727
- const includeInventory = options.variantIncludeInventory !== false;
728
-
729
- // additionalProperty: only variant-differentiating specs
730
- const specificationsAdditionalProperty = isLegacySku(sku)
731
- ? toAdditionalPropertiesLegacy(sku)
732
- : toAdditionalProperties(sku);
733
- const additionalProperty = specificationsAdditionalProperty.filter((prop) =>
734
- variantProps.has(prop.name ?? ""),
735
- );
736
-
737
- // Offers: best seller, lean (availability + seller; optional inventoryLevel)
738
- const offerConverter = isLegacyProduct(product) ? toOfferLegacy : toOffer;
739
- const allOffers = (sku.sellers ?? []).map(offerConverter).sort(bestOfferFirst);
740
- const bestOffer = allOffers[0];
741
- const leanOffers = bestOffer ? [buildOfferVariant(bestOffer, includeInventory)] : [];
742
-
743
- // image[0] only — selectors render a single thumbnail. Reuse the same
744
- // imagesByKey lookup toProduct uses so URLs stay consistent across variants.
745
- const imagesByKey =
746
- options.imagesByKey ??
747
- items
748
- .flatMap((i) => i.images)
749
- .reduce((map, img) => {
750
- img?.imageUrl && map.set(getImageKey(img.imageUrl), img.imageUrl);
751
- return map;
752
- }, new Map<string, string>());
753
-
754
- const image = includeImage
755
- ? nonEmptyArray(sku.images)
756
- ?.slice(0, 1)
757
- .map(({ imageUrl, imageText, imageLabel }) => ({
758
- "@type": "ImageObject" as const,
759
- alternateName: imageText || imageLabel || "",
760
- url: imagesByKey.get(getImageKey(imageUrl)) ?? imageUrl,
761
- name: imageLabel || "",
762
- encodingFormat: "image",
763
- }))
764
- : undefined;
765
-
766
- return {
767
- "@type": "Product",
768
- productID: skuId,
769
- sku: skuId,
770
- name,
771
- url: getProductURL(baseUrl, product, sku.itemId).href,
772
- inProductGroupWithID: productId,
773
- additionalProperty,
774
- ...(image ? { image } : {}),
775
- offers: aggregateOffers(leanOffers, priceCurrency),
776
- };
777
- };
778
-
779
- const toBreadcrumbList = (
780
- product: ProductVTEX | LegacyProductVTEX,
781
- { baseUrl }: ProductOptions,
782
- ): BreadcrumbList => {
783
- const { categories, productName } = product;
784
- const names = categories[0]?.split("/").filter(Boolean);
785
-
786
- const segments = names.map(slugify);
787
-
788
- return {
789
- "@type": "BreadcrumbList",
790
- itemListElement: [
791
- ...names.map((name, index) => {
792
- const position = index + 1;
793
-
794
- return {
795
- "@type": "ListItem" as const,
796
- name,
797
- item: new URL(`/${segments.slice(0, position).join("/")}`, baseUrl).href,
798
- position,
799
- };
800
- }),
801
- {
802
- "@type": "ListItem",
803
- name: productName,
804
- item: getProductGroupURL(baseUrl, product).href,
805
- position: categories.length + 1,
806
- },
807
- ],
808
- numberOfItems: categories.length + 1,
809
- };
810
- };
811
-
812
- const legacyToProductGroupAdditionalProperties = (product: LegacyProductVTEX) => {
813
- const groups = product.allSpecificationsGroups ?? [];
814
- const allSpecifications = product.allSpecifications ?? [];
815
-
816
- const specByGroup: Record<string, string> = {};
817
-
818
- groups.forEach((group) => {
819
- const groupSpecs = (product as unknown as Record<string, string[]>)[group];
820
- groupSpecs.forEach((specName) => {
821
- specByGroup[specName] = group;
822
- });
823
- });
824
-
825
- return allSpecifications.flatMap((name) => {
826
- const values = (product as unknown as Record<string, string[]>)[name];
827
- return values.map((value) =>
828
- toAdditionalPropertySpecification({
829
- name,
830
- value,
831
- propertyID: specByGroup[name],
832
- }),
833
- );
834
- });
835
- };
836
-
837
- const toProductGroupAdditionalProperties = ({ specificationGroups = [] }: ProductVTEX) =>
838
- specificationGroups.flatMap(({ name: groupName, specifications }) =>
839
- specifications.flatMap(({ name, values }) =>
840
- values.map(
841
- (value) =>
842
- ({
843
- "@type": "PropertyValue",
844
- name,
845
- value,
846
- propertyID: groupName,
847
- valueReference: "PROPERTY" as string,
848
- }) as const,
849
- ),
850
- ),
851
- );
852
-
853
- const toOriginalAttributesAdditionalProperties = (
854
- originalAttributes: Maybe<string[]>,
855
- product: ProductVTEX | LegacyProduct,
856
- ) => {
857
- if (!originalAttributes) {
858
- return [];
859
- }
860
-
861
- const attributes = pick(originalAttributes as Array<keyof typeof product>, product) ?? {};
862
-
863
- return Object.entries(attributes).map(
864
- ([name, value]) =>
865
- ({
866
- "@type": "PropertyValue",
867
- name,
868
- value,
869
- valueReference: "ORIGINAL_PROPERTY" as string,
870
- }) as const,
871
- ) as unknown as PropertyValue[];
872
- };
873
-
874
- const toAdditionalProperties = (sku: SkuVTEX): PropertyValue[] =>
875
- sku.variations?.flatMap(({ name, values }) =>
876
- values.map((value) => toAdditionalPropertySpecification({ name, value })),
877
- ) ?? [];
878
-
879
- export const toAdditionalPropertySpecification = ({
880
- name,
881
- value,
882
- propertyID,
883
- }: {
884
- name: string;
885
- value: string;
886
- propertyID?: string;
887
- }): PropertyValue => ({
888
- "@type": "PropertyValue",
889
- name,
890
- value,
891
- propertyID,
892
- valueReference: "SPECIFICATION",
893
- });
894
-
895
- const toAdditionalPropertiesLegacy = (sku: LegacySkuVTEX): PropertyValue[] => {
896
- const { variations = [], attachments = [] } = sku;
897
-
898
- const specificationProperties = variations.flatMap((variation) =>
899
- sku[variation].map((value) => toAdditionalPropertySpecification({ name: variation, value })),
900
- );
901
-
902
- const attachmentProperties = attachments.map(
903
- (attachment) =>
904
- ({
905
- "@type": "PropertyValue",
906
- propertyID: `${attachment.id}`,
907
- name: attachment.name,
908
- value: attachment.domainValues,
909
- required: attachment.required,
910
- valueReference: "ATTACHMENT",
911
- }) as const,
912
- );
913
-
914
- if (attachmentProperties.length === 0) return specificationProperties;
915
- specificationProperties.push(...attachmentProperties);
916
- return specificationProperties;
917
- };
918
-
919
- const buildOffer = (
920
- { commertialOffer: offer, sellerId, sellerName, sellerDefault }: SellerVTEX,
921
- teasers: Teasers[],
922
- ): Offer => ({
923
- "@type": "Offer",
924
- identifier: sellerDefault ? "default" : undefined,
925
- price: offer.spotPrice ?? offer.Price,
926
- seller: sellerId,
927
- sellerName,
928
- priceValidUntil: offer.PriceValidUntil,
929
- inventoryLevel: { value: offer.AvailableQuantity },
930
- giftSkuIds: offer.GiftSkuIds ?? [],
931
- teasers,
932
- priceSpecification: [
933
- {
934
- "@type": "UnitPriceSpecification",
935
- priceType: SCHEMA_LIST_PRICE,
936
- price: offer.ListPrice,
937
- },
938
- {
939
- "@type": "UnitPriceSpecification",
940
- priceType: SCHEMA_SALE_PRICE,
941
- price: offer.Price,
942
- },
943
- {
944
- "@type": "UnitPriceSpecification",
945
- priceType: SCHEMA_SRP,
946
- price: offer.PriceWithoutDiscount,
947
- },
948
- ...offer.Installments.map(
949
- (installment): UnitPriceSpecification => ({
950
- "@type": "UnitPriceSpecification",
951
- priceType: SCHEMA_SALE_PRICE,
952
- priceComponentType: SCHEMA_INSTALLMENT,
953
- name: installment.PaymentSystemName,
954
- description: installment.Name,
955
- billingDuration: installment.NumberOfInstallments,
956
- billingIncrement: installment.Value,
957
- price: installment.TotalValuePlusInterestRate,
958
- }),
959
- ),
960
- ],
961
- availability: offer.AvailableQuantity > 0 ? SCHEMA_IN_STOCK : SCHEMA_OUT_OF_STOCK,
962
- });
963
-
964
- const toOffer = (seller: SellerVTEX): Offer =>
965
- buildOffer(seller, seller.commertialOffer.teasers ?? []);
966
-
967
- const toOfferLegacy = (seller: SellerVTEX): Offer => {
968
- const otherTeasers =
969
- seller.commertialOffer.DiscountHighLight?.map((i) => {
970
- const discount = i as Record<string, string>;
971
- const [_k__BackingField, discountName] = Object.entries(discount)?.[0] ?? [];
972
-
973
- const teasers: Teasers = {
974
- name: discountName,
975
- conditions: {
976
- minimumQuantity: 0,
977
- parameters: [],
978
- },
979
- effects: {
980
- parameters: [],
981
- },
982
- };
983
-
984
- return teasers;
985
- }) ?? [];
986
-
987
- const legacyTeasers = (seller.commertialOffer.Teasers ?? []).map((teaser) => ({
988
- name: teaser["<Name>k__BackingField"],
989
- generalValues: teaser["<GeneralValues>k__BackingField"],
990
- conditions: {
991
- minimumQuantity: teaser["<Conditions>k__BackingField"]["<MinimumQuantity>k__BackingField"],
992
- parameters: teaser["<Conditions>k__BackingField"]["<Parameters>k__BackingField"].map(
993
- (parameter) => ({
994
- name: parameter["<Name>k__BackingField"],
995
- value: parameter["<Value>k__BackingField"],
996
- }),
997
- ),
998
- },
999
- effects: {
1000
- parameters: teaser["<Effects>k__BackingField"]["<Parameters>k__BackingField"].map(
1001
- (parameter) => ({
1002
- name: parameter["<Name>k__BackingField"],
1003
- value: parameter["<Value>k__BackingField"],
1004
- }),
1005
- ),
1006
- },
1007
- }));
1008
-
1009
- return buildOffer(seller, [...otherTeasers, ...legacyTeasers]);
1010
- };
1011
-
1012
- export const legacyFacetToFilter = (
1013
- name: string,
1014
- facets: LegacyFacet[],
1015
- url: URL,
1016
- map: string,
1017
- term: string,
1018
- behavior: "dynamic" | "static",
1019
- ignoreCaseSelected?: boolean,
1020
- fullPath = false,
1021
- ): Filter | null => {
1022
- const mapSegments = map.split(",").filter((x) => x.length > 0);
1023
- const pathSegments = term.replace(/^\//, "").split("/").slice(0, mapSegments.length);
1024
-
1025
- const mapSet = new Set(mapSegments.map((i) => (ignoreCaseSelected ? i.toLowerCase() : i)));
1026
- const pathSet = new Set(pathSegments.map((i) => (ignoreCaseSelected ? i.toLowerCase() : i)));
1027
-
1028
- // for productClusterIds, we have to use the full path
1029
- // example:
1030
- // category2/123?map=c,productClusterIds -> DO NOT WORK
1031
- // category1/category2/123?map=c,c,productClusterIds -> WORK
1032
- const hasProductClusterIds = mapSegments.includes("productClusterIds");
1033
- const hasToBeFullpath =
1034
- fullPath || hasProductClusterIds || mapSegments.includes("ft") || mapSegments.includes("b");
1035
-
1036
- const getLink = (facet: LegacyFacet, selected: boolean) => {
1037
- const index = pathSegments.findIndex((s) => {
1038
- if (ignoreCaseSelected) {
1039
- return s.toLowerCase() === facet.Value.toLowerCase();
1040
- }
1041
-
1042
- return s === facet.Value;
1043
- });
1044
-
1045
- const map = hasToBeFullpath ? facet.Link.split("map=")[1].split(",") : [facet.Map];
1046
- const value = hasToBeFullpath ? facet.Link.split("?")[0].slice(1).split("/") : [facet.Value];
1047
-
1048
- const pathSegmentsFiltered = hasProductClusterIds
1049
- ? [pathSegments[mapSegments.indexOf("productClusterIds")]]
1050
- : [];
1051
- const mapSegmentsFiltered = hasProductClusterIds ? ["productClusterIds"] : [];
1052
-
1053
- const _mapSegments = hasToBeFullpath ? mapSegmentsFiltered : mapSegments;
1054
- const _pathSegments = hasToBeFullpath ? pathSegmentsFiltered : pathSegments;
1055
-
1056
- const newMap = selected
1057
- ? [...mapSegments.filter((_, i) => i !== index)]
1058
- : [..._mapSegments, ...map];
1059
- const newPath = selected
1060
- ? [...pathSegments.filter((_, i) => i !== index)]
1061
- : [..._pathSegments, ...value];
1062
-
1063
- // Insertion-sort like algorithm. Uses the c-continuum theorem
1064
- const zipped: [string, string][] = [];
1065
- for (let it = 0; it < newMap.length; it++) {
1066
- let i = 0;
1067
- while (i < zipped.length && (zipped[i][0] === "c" || zipped[i][0] === "C")) i++;
1068
-
1069
- zipped.splice(i, 0, [newMap[it], newPath[it]]);
1070
- }
1071
-
1072
- const link = new URL(`/${zipped.map(([, s]) => s).join("/")}`, url);
1073
- link.searchParams.set("map", zipped.map(([m]) => m).join(","));
1074
- if (behavior === "static") {
1075
- link.searchParams.set("fmap", url.searchParams.get("fmap") || mapSegments.join(","));
1076
- }
1077
- const currentQuery = url.searchParams.get("q");
1078
- if (currentQuery) {
1079
- link.searchParams.set("q", currentQuery);
1080
- }
1081
-
1082
- return `${link.pathname}${link.search}`;
1083
- };
1084
-
1085
- return {
1086
- "@type": "FilterToggle",
1087
- quantity: facets?.length,
1088
- label: name,
1089
- key: name,
1090
- values: facets.map((facet) => {
1091
- const normalizedFacet = name !== "PriceRanges" ? facet : normalizeFacet(facet);
1092
-
1093
- const selected =
1094
- mapSet.has(ignoreCaseSelected ? normalizedFacet.Map.toLowerCase() : normalizedFacet.Map) &&
1095
- pathSet.has(
1096
- ignoreCaseSelected ? normalizedFacet.Value.toLowerCase() : normalizedFacet.Value,
1097
- );
1098
-
1099
- return {
1100
- value: normalizedFacet.Value,
1101
- quantity: normalizedFacet.Quantity,
1102
- url: getLink(normalizedFacet, selected),
1103
- label: normalizedFacet.Name,
1104
- selected,
1105
- children:
1106
- facet.Children?.length > 0
1107
- ? legacyFacetToFilter(
1108
- normalizedFacet.Name,
1109
- facet.Children,
1110
- url,
1111
- map,
1112
- term,
1113
- behavior,
1114
- ignoreCaseSelected,
1115
- fullPath,
1116
- )
1117
- : undefined,
1118
- };
1119
- }),
1120
- };
1121
- };
1122
-
1123
- export const filtersToSearchParams = (
1124
- selectedFacets: SelectedFacet[],
1125
- paramsToPersist?: URLSearchParams,
1126
- ) => {
1127
- const searchParams = new URLSearchParams(paramsToPersist);
1128
-
1129
- for (const { key, value } of selectedFacets) {
1130
- searchParams.append(`filter.${key}`, value);
1131
- }
1132
-
1133
- return searchParams;
1134
- };
1135
-
1136
- const fromLegacyMap: Record<string, string> = {
1137
- priceFrom: "price",
1138
- productClusterSearchableIds: "productClusterIds",
1139
- };
1140
-
1141
- export const legacyFacetsNormalize = (map: string, path: string) => {
1142
- // Replace legacy price path param to IS price facet format
1143
- // exemple: de-34,90-a-56,90 turns to 34.90:56.90
1144
- // may this regex have to be adjusted for international stores
1145
- const value = path.replace(
1146
- /de-(?<from>\d+[,]?[\d]+)-a-(?<to>\d+[,]?[\d]+)/,
1147
- (_match, from, to) => {
1148
- return `${from.replace(",", ".")}:${to.replace(",", ".")}`;
1149
- },
1150
- );
1151
-
1152
- const key = fromLegacyMap[map] || map;
1153
-
1154
- return { key, value };
1155
- };
1156
-
1157
- /**
1158
- * Transform ?map urls into selected facets. This happens when a store is migrating
1159
- * to Deco and also migrating from VTEX Legacy to VTEX Intelligent Search.
1160
- */
1161
- export const legacyFacetsFromURL = (url: URL) => {
1162
- const mapSegments = url.searchParams.get("map")?.split(",") ?? [];
1163
- const pathSegments = url.pathname.split("/").slice(1); // Remove first slash
1164
- const length = Math.min(mapSegments.length, pathSegments.length);
1165
-
1166
- const selectedFacets: SelectedFacet[] = [];
1167
- for (let it = 0; it < length; it++) {
1168
- const facet = legacyFacetsNormalize(mapSegments[it], pathSegments[it]);
1169
-
1170
- selectedFacets.push(facet);
1171
- }
1172
-
1173
- return selectedFacets;
1174
- };
1175
-
1176
- export const filtersFromURL = (url: URL) => {
1177
- const selectedFacets: SelectedFacet[] = legacyFacetsFromURL(url);
1178
-
1179
- url.searchParams.forEach((value, name) => {
1180
- const [filter, key] = name.split(".");
1181
-
1182
- if (filter === "filter" && typeof key === "string") {
1183
- selectedFacets.push({ key, value });
1184
- }
1185
- });
1186
-
1187
- return selectedFacets;
1188
- };
1189
-
1190
- export const mergeFacets = (f1: SelectedFacet[], f2: SelectedFacet[]): SelectedFacet[] => {
1191
- const facetKey = (facet: SelectedFacet) => `key:${facet.key}-value:${facet.value}`;
1192
- const merged = new Map<string, SelectedFacet>();
1193
-
1194
- for (const f of f1) {
1195
- merged.set(facetKey(f), f);
1196
- }
1197
- for (const f of f2) {
1198
- merged.set(facetKey(f), f);
1199
- }
1200
-
1201
- return [...merged.values()];
1202
- };
1203
-
1204
- const isValueRange = (facet: FacetValueRange | FacetValueBoolean): facet is FacetValueRange =>
1205
- // deno-lint-ignore no-explicit-any
1206
- Boolean((facet as any).range);
1207
-
1208
- const facetToToggle =
1209
- (selectedFacets: SelectedFacet[], key: string, paramsToPersist?: URLSearchParams) =>
1210
- (item: FacetValueRange | FacetValueBoolean): FilterToggleValue => {
1211
- const { quantity, selected } = item;
1212
- const isRange = isValueRange(item);
1213
-
1214
- const value = isRange ? formatRange(item.range.from, item.range.to) : item.value;
1215
- const label = isRange ? value : item.name;
1216
- const facet = { key, value };
1217
-
1218
- const filters = selected
1219
- ? selectedFacets.filter((f) => f.key !== key || f.value !== value)
1220
- : [...selectedFacets, facet];
1221
-
1222
- return {
1223
- value,
1224
- quantity,
1225
- selected,
1226
- url: `?${filtersToSearchParams(filters, paramsToPersist)}`,
1227
- label,
1228
- };
1229
- };
1230
-
1231
- export const toFilter =
1232
- (selectedFacets: SelectedFacet[], paramsToPersist?: URLSearchParams) =>
1233
- ({ key, name, quantity, values }: FacetVTEX): Filter => ({
1234
- "@type": "FilterToggle",
1235
- key,
1236
- label: name,
1237
- quantity: quantity,
1238
- values: values.map(facetToToggle(selectedFacets, key, paramsToPersist)),
1239
- });
1240
-
1241
- function nodeToNavbar(node: Category): SiteNavigationElement {
1242
- const url = new URL(node.url, "https://example.com");
1243
-
1244
- return {
1245
- "@type": "SiteNavigationElement",
1246
- url: `${url.pathname}${url.search}`,
1247
- name: node.name,
1248
- children: node.children.map(nodeToNavbar),
1249
- };
1250
- }
1251
-
1252
- export const categoryTreeToNavbar = (tree: Category[]): SiteNavigationElement[] =>
1253
- tree.map(nodeToNavbar);
1254
-
1255
- export const toBrand = (
1256
- { id, name, imageUrl, metaTagDescription }: BrandVTEX,
1257
- baseUrl: string,
1258
- ): Brand => ({
1259
- "@type": "Brand",
1260
- "@id": `${id}`,
1261
- name,
1262
- logo: imageUrl?.startsWith("http") ? imageUrl : `${baseUrl}${imageUrl}`,
1263
- description: metaTagDescription,
1264
- });
1265
-
1266
- export const normalizeFacet = (facet: LegacyFacet) => {
1267
- return {
1268
- ...facet,
1269
- Map: "priceFrom",
1270
- Value: facet.Slug!,
1271
- };
1272
- };
1273
-
1274
- export const toReview = (
1275
- products: Product[],
1276
- ratings: ProductRating[],
1277
- reviews: ProductReviewData[],
1278
- ): Product[] => {
1279
- return products.map((p, index) => {
1280
- const ratingsCount = ratings[index].totalCount || 0;
1281
- const productReviews = reviews[index].data || [];
1282
-
1283
- return {
1284
- ...p,
1285
- aggregateRating: {
1286
- "@type": "AggregateRating",
1287
- reviewCount: ratingsCount,
1288
- ratingCount: ratingsCount,
1289
- ratingValue: ratings[index]?.average || 0,
1290
- },
1291
- review: productReviews.map((_, reviewIndex) => ({
1292
- "@type": "Review",
1293
- id: productReviews[reviewIndex]?.id?.toString(),
1294
- author: [
1295
- {
1296
- "@type": "Author",
1297
- name: productReviews[reviewIndex]?.reviewerName,
1298
- verifiedBuyer: productReviews[reviewIndex]?.verifiedPurchaser,
1299
- },
1300
- ],
1301
- itemReviewed: productReviews[reviewIndex]?.productId,
1302
- datePublished: productReviews[reviewIndex]?.reviewDateTime,
1303
- reviewHeadline: productReviews[reviewIndex]?.title,
1304
- reviewBody: productReviews[reviewIndex]?.text,
1305
- reviewRating: {
1306
- "@type": "AggregateRating",
1307
- ratingValue: productReviews[reviewIndex]?.rating || 0,
1308
- },
1309
- })),
1310
- };
1311
- });
1312
- };
1313
-
1314
- export const toInventories = (
1315
- products: Product[],
1316
- inventoriesData: ProductInventoryData[],
1317
- ): Product[] => {
1318
- return products.map((p, index) => {
1319
- const balance = inventoriesData[index].balance || [];
1320
-
1321
- const additionalProperty = Array.from(p.additionalProperty || []);
1322
-
1323
- const inventories: PropertyValue[] = balance.map((b) => ({
1324
- "@type": "PropertyValue",
1325
- valueReference: "INVENTORY",
1326
- propertyID: b.warehouseId,
1327
- name: b.warehouseName,
1328
- value: b.totalQuantity?.toString(),
1329
- }));
1330
-
1331
- return {
1332
- ...p,
1333
- additionalProperty: [...additionalProperty, ...inventories],
1334
- };
1335
- });
1336
- };
1337
-
1338
- type ProductMap = Record<string, Product>;
1339
-
1340
- export const sortProducts = (
1341
- products: Product[],
1342
- orderOfIdsOrSkus: string[],
1343
- prop: "sku" | "inProductGroupWithID",
1344
- ) => {
1345
- const productMap: ProductMap = {};
1346
-
1347
- products.forEach((product) => {
1348
- productMap[product[prop] || product.sku] = product;
1349
- });
1350
-
1351
- return orderOfIdsOrSkus.map((id) => productMap[id]);
1352
- };
1353
-
1354
- export const parsePageType = (p: PageTypeVTEX): PageType => {
1355
- const type = p.pageType;
1356
-
1357
- // Search or Busca vazia
1358
- if (type === "FullText") {
1359
- return "Search";
1360
- }
1361
-
1362
- // A page that vtex doesn't recognize
1363
- if (type === "NotFound") {
1364
- return "Unknown";
1365
- }
1366
-
1367
- return type;
1368
- };
1369
-
1370
- function dayOfWeekIndexToString(day?: number): DayOfWeek | undefined {
1371
- switch (day) {
1372
- case 0:
1373
- return "Sunday";
1374
- case 1:
1375
- return "Monday";
1376
- case 2:
1377
- return "Tuesday";
1378
- case 3:
1379
- return "Wednesday";
1380
- case 4:
1381
- return "Thursday";
1382
- case 5:
1383
- return "Friday";
1384
- case 6:
1385
- return "Saturday";
1386
- default:
1387
- return undefined;
1388
- }
1389
- }
1390
-
1391
- interface Hours {
1392
- dayOfWeek?: number;
1393
- openingTime?: string;
1394
- closingTime?: string;
1395
- }
1396
-
1397
- function toHoursSpecification(hours: Hours): OpeningHoursSpecification {
1398
- return {
1399
- "@type": "OpeningHoursSpecification",
1400
- opens: hours.openingTime,
1401
- closes: hours.closingTime,
1402
- dayOfWeek: dayOfWeekIndexToString(hours.dayOfWeek),
1403
- };
1404
- }
1405
-
1406
- function toSpecialHoursSpecification(holiday: PickupHolidays): OpeningHoursSpecification {
1407
- const dateHoliday = new Date(holiday.date ?? "");
1408
- // VTEX provide date in ISO format, at 00h on the day
1409
- const validThrough = dateHoliday.setDate(dateHoliday.getDate() + 1).toString();
1410
-
1411
- return {
1412
- "@type": "OpeningHoursSpecification",
1413
- opens: holiday.hourBegin,
1414
- closes: holiday.hourEnd,
1415
- validFrom: holiday.date,
1416
- validThrough,
1417
- };
1418
- }
1419
-
1420
- function isPickupPointVCS(
1421
- pickupPoint: PickupPoint | PickupPointVCS,
1422
- ): pickupPoint is PickupPointVCS {
1423
- return "name" in pickupPoint;
1424
- }
1425
-
1426
- interface ToPlaceOptions {
1427
- isActive?: boolean;
1428
- }
1429
-
1430
- export function toPlace(
1431
- pickupPoint: (PickupPoint & { distance?: number }) | PickupPointVCS,
1432
- options?: ToPlaceOptions,
1433
- ): Place {
1434
- const {
1435
- name,
1436
- country,
1437
- latitude,
1438
- longitude,
1439
- openingHoursSpecification,
1440
- specialOpeningHoursSpecification,
1441
- isActive,
1442
- } = isPickupPointVCS(pickupPoint)
1443
- ? {
1444
- name: pickupPoint.name,
1445
- country: pickupPoint.address?.country?.acronym,
1446
- latitude: pickupPoint.address?.location?.latitude,
1447
- longitude: pickupPoint.address?.location?.longitude,
1448
- specialOpeningHoursSpecification: pickupPoint.pickupHolidays?.map(
1449
- toSpecialHoursSpecification,
1450
- ),
1451
- openingHoursSpecification: pickupPoint.businessHours?.map(toHoursSpecification),
1452
- isActive: pickupPoint.isActive,
1453
- }
1454
- : {
1455
- name: pickupPoint.friendlyName,
1456
- country: pickupPoint.address?.country,
1457
- latitude: pickupPoint.address?.geoCoordinates?.[0],
1458
- longitude: pickupPoint.address?.geoCoordinates?.[1],
1459
- specialOpeningHoursSpecification: pickupPoint.pickupHolidays?.map(
1460
- toSpecialHoursSpecification,
1461
- ),
1462
- openingHoursSpecification: pickupPoint.businessHours?.map(
1463
- ({ ClosingTime, DayOfWeek, OpeningTime }) =>
1464
- toHoursSpecification({
1465
- closingTime: ClosingTime,
1466
- dayOfWeek: DayOfWeek,
1467
- openingTime: OpeningTime,
1468
- }),
1469
- ),
1470
- isActive: options?.isActive,
1471
- };
1472
-
1473
- return {
1474
- "@id": pickupPoint.id,
1475
- "@type": "Place",
1476
- address: {
1477
- "@type": "PostalAddress",
1478
- addressCountry: country,
1479
- addressLocality: pickupPoint.address?.city,
1480
- addressRegion: pickupPoint.address?.state,
1481
- postalCode: pickupPoint.address?.postalCode,
1482
- streetAddress: pickupPoint.address?.street,
1483
- },
1484
- latitude,
1485
- longitude,
1486
- name,
1487
- specialOpeningHoursSpecification,
1488
- openingHoursSpecification,
1489
- additionalProperty: [
1490
- {
1491
- "@type": "PropertyValue",
1492
- name: "distance",
1493
- value: `${pickupPoint.distance}`,
1494
- },
1495
- {
1496
- "@type": "PropertyValue",
1497
- name: "isActive",
1498
- value: typeof isActive === "boolean" ? `${isActive}` : undefined,
1499
- },
1500
- ],
1501
- };
1502
- }
1503
-
1504
- export const toPostalAddress = (address: Address): PostalAddress => {
1505
- return {
1506
- "@type": "PostalAddress",
1507
- "@id": address.addressId,
1508
- addressCountry: address.country,
1509
- addressLocality: address.city,
1510
- addressRegion: address.state,
1511
- areaServed: address.neighborhood || undefined,
1512
- postalCode: address.postalCode,
1513
- streetAddress: address.street,
1514
- identifier: address.number || undefined,
1515
- name: address.addressName || undefined,
1516
- alternateName: address.receiverName || undefined,
1517
- description: address.complement || undefined,
1518
- disambiguatingDescription: address.reference || undefined,
1519
- latitude: address.geoCoordinates?.[0] || undefined,
1520
- longitude: address.geoCoordinates?.[1] || undefined,
1521
- };
1522
- };