@decocms/apps-vtex 7.59.1 → 7.61.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps-vtex",
3
- "version": "7.59.1",
3
+ "version": "7.61.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce app: VTEX integration",
6
6
  "repository": {
@@ -51,10 +51,10 @@
51
51
  "lint:unused": "knip"
52
52
  },
53
53
  "dependencies": {
54
- "@decocms/blocks": "7.59.1",
55
- "@decocms/apps-commerce": "7.59.1",
56
- "@decocms/apps-website": "7.59.1",
57
- "@decocms/tanstack": "7.59.1"
54
+ "@decocms/blocks": "7.61.0",
55
+ "@decocms/apps-commerce": "7.61.0",
56
+ "@decocms/apps-website": "7.61.0",
57
+ "@decocms/tanstack": "7.61.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@tanstack/react-query": ">=5.0.0",
@@ -0,0 +1,157 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // `legacyProductList` is the SHELF loader. It accepted no payload-shaping
4
+ // options, so every shelf paid the full product for every entry — measured on a
5
+ // real home shelf (`count: 28`): 1095 KB, 39.1 KB per product, of which
6
+ // `isVariantOf` 19.1 KB and the 48-rung `offers.priceSpecification` 12.8 KB.
7
+ // `legacyProductListingPage` already took them; this file pins the pass-through,
8
+ // because declaring the options WITHOUT forwarding them is exactly the bug the
9
+ // PLP loader had.
10
+
11
+ vi.mock("../../client", () => ({
12
+ getVtexConfig: () => ({ account: "test", salesChannel: "1" }),
13
+ vtexFetch: vi.fn(),
14
+ vtexFetchResponse: vi.fn(),
15
+ }));
16
+
17
+ import { vtexFetch } from "../../client";
18
+ import { legacyProductList } from "../legacy";
19
+
20
+ const sellers = (price: number) => [
21
+ {
22
+ sellerId: "1",
23
+ sellerName: "Seller One",
24
+ sellerDefault: true,
25
+ commertialOffer: {
26
+ AvailableQuantity: 5,
27
+ Price: price,
28
+ ListPrice: price + 30,
29
+ PriceWithoutDiscount: price + 30,
30
+ spotPrice: price,
31
+ PriceValidUntil: "2025-12-31",
32
+ // A real ladder: every method the store accepts, every installment count.
33
+ Installments: ["Visa", "Master", "Amex", "Boleto"].flatMap((method) =>
34
+ Array.from({ length: 4 }, (_, i) => ({
35
+ Value: price / (i + 1),
36
+ NumberOfInstallments: i + 1,
37
+ Name: `${method} ${i + 1}x`,
38
+ InterestRate: 0,
39
+ TotalValuePlusInterestRate: price,
40
+ PaymentSystemName: method,
41
+ })),
42
+ ),
43
+ GiftSkuIds: [],
44
+ teasers: [],
45
+ },
46
+ },
47
+ ];
48
+
49
+ const sku = (itemId: string, price: number) => ({
50
+ itemId,
51
+ name: `SKU ${itemId}`,
52
+ nameComplete: `SKU ${itemId}`,
53
+ complementName: "",
54
+ ean: "1234567890123",
55
+ referenceId: [{ Key: "RefId", Value: `REF-${itemId}` }],
56
+ images: Array.from({ length: 4 }, (_, i) => ({
57
+ imageId: `${itemId}-${i}`,
58
+ imageUrl: `https://img.com/${itemId}-${i}.jpg`,
59
+ imageText: `img${i}`,
60
+ imageLabel: `label${i}`,
61
+ })),
62
+ sellers: sellers(price),
63
+ Videos: [],
64
+ estimatedDateArrival: null,
65
+ measurementUnit: "un",
66
+ unitMultiplier: 1,
67
+ variations: [],
68
+ attachments: [],
69
+ isKit: false,
70
+ });
71
+
72
+ const legacyProduct = () => ({
73
+ productId: "PROD1",
74
+ productName: "Test Product",
75
+ brand: "TestBrand",
76
+ brandId: 1,
77
+ brandImageUrl: null,
78
+ linkText: "test-product",
79
+ productReference: "REF1",
80
+ categoryId: "1",
81
+ productTitle: "Test Product",
82
+ metaTagDescription: "meta",
83
+ clusterHighlights: {},
84
+ productClusters: {},
85
+ searchableClusters: {},
86
+ categories: ["/Electronics/"],
87
+ categoriesIds: ["/1/"],
88
+ link: "https://test/test-product/p",
89
+ description: "x".repeat(2000),
90
+ items: [sku("SKU1", 90), sku("SKU2", 60), sku("SKU3", 120)],
91
+ allSpecifications: [],
92
+ allSpecificationsGroups: [],
93
+ skuSpecifications: [],
94
+ releaseDate: "2024-01-01",
95
+ });
96
+
97
+ const opts = { query: { count: 1 } as any, baseUrl: "https://example.com" };
98
+ const ladderOf = (p: any) => p?.offers?.offers?.[0]?.priceSpecification ?? [];
99
+
100
+ describe("legacyProductList — payload-shaping options reach toProduct", () => {
101
+ beforeEach(() => {
102
+ (vtexFetch as any).mockReset();
103
+ (vtexFetch as any).mockResolvedValue([legacyProduct()]);
104
+ });
105
+
106
+ it("no options: full product, full ladder, every variant, every image", async () => {
107
+ const [p] = (await legacyProductList(opts)) as any[];
108
+ expect(ladderOf(p).length).toBeGreaterThan(10);
109
+ expect(p.isVariantOf.hasVariant).toHaveLength(3);
110
+ expect(ladderOf(p.isVariantOf.hasVariant[0]).length).toBeGreaterThan(10);
111
+ expect(p.image).toHaveLength(4);
112
+ expect(p.description).toBeTruthy();
113
+ });
114
+
115
+ it("leanVariants empties the ladder on every variant", async () => {
116
+ const [p] = (await legacyProductList({ ...opts, leanVariants: true })) as any[];
117
+ for (const v of p.isVariantOf.hasVariant) expect(ladderOf(v)).toEqual([]);
118
+ });
119
+
120
+ it("displayedVariantId keeps the ladder on the one variant the card renders", async () => {
121
+ const [p] = (await legacyProductList({
122
+ ...opts,
123
+ leanVariants: true,
124
+ displayedVariantId: (items: any[]) => items[1].itemId,
125
+ })) as any[];
126
+ const kept = p.isVariantOf.hasVariant.find((v: any) => v.sku === "SKU2");
127
+ expect(ladderOf(kept).length).toBeGreaterThan(0);
128
+ // ...and it is the LEAN shape, not a second full product.
129
+ expect(kept.description).toBeUndefined();
130
+ });
131
+
132
+ it("priceSpecifications rewrites the root ladder with the caller's rule", async () => {
133
+ const [p] = (await legacyProductList({
134
+ ...opts,
135
+ priceSpecifications: (specs) => specs.filter((s) => !s.priceComponentType),
136
+ })) as any[];
137
+ expect(ladderOf(p).every((s: any) => !s.priceComponentType)).toBe(true);
138
+ expect(ladderOf(p).length).toBeLessThan(10);
139
+ });
140
+
141
+ it("maxImages caps image[] by position", async () => {
142
+ const [p] = (await legacyProductList({ ...opts, maxImages: 2 })) as any[];
143
+ expect(p.image).toHaveLength(2);
144
+ });
145
+
146
+ it("every option absent is byte-for-byte the previous output", async () => {
147
+ const [before] = (await legacyProductList(opts)) as any[];
148
+ const [after] = (await legacyProductList({
149
+ ...opts,
150
+ leanVariants: undefined,
151
+ displayedVariantId: undefined,
152
+ maxImages: undefined,
153
+ priceSpecifications: undefined,
154
+ })) as any[];
155
+ expect(JSON.stringify(after)).toBe(JSON.stringify(before));
156
+ });
157
+ });
@@ -33,6 +33,7 @@ import {
33
33
  toProduct,
34
34
  toProductPage,
35
35
  } from "../utils/transform";
36
+ import type { ProductOptions } from "../utils/transform";
36
37
  import type { LegacyFacet, LegacyItem, LegacyProduct, LegacySort, PageType } from "../utils/types";
37
38
 
38
39
  // ---------------------------------------------------------------------------
@@ -158,6 +159,23 @@ export interface LegacyProductListOptions {
158
159
  query: LegacyProductListQuery;
159
160
  baseUrl: string;
160
161
  priceCurrency?: string;
162
+ /**
163
+ * Payload-shaping options, forwarded verbatim to `toProduct`. Same set the
164
+ * PLP loader already accepts, and for the same reason: a shelf renders a
165
+ * CARD, but this loader built the full product for every entry. Measured on
166
+ * a real home shelf (`count: 28`), 1095 KB — 39.1 KB per product, of which
167
+ * `isVariantOf` 19.1 KB and the 48-rung `offers.priceSpecification` 12.8 KB.
168
+ *
169
+ * All default to undefined, which keeps the previous output byte for byte.
170
+ * See the matching fields on {@link ProductOptions}.
171
+ */
172
+ leanVariants?: ProductOptions["leanVariants"];
173
+ displayedVariantId?: ProductOptions["displayedVariantId"];
174
+ variantPropertyNames?: ProductOptions["variantPropertyNames"];
175
+ variantIncludeImage?: ProductOptions["variantIncludeImage"];
176
+ variantIncludeInventory?: ProductOptions["variantIncludeInventory"];
177
+ maxImages?: ProductOptions["maxImages"];
178
+ priceSpecifications?: ProductOptions["priceSpecifications"];
161
179
  }
162
180
 
163
181
  function isCollectionQuery(
@@ -233,7 +251,18 @@ function queryToSearchParams(
233
251
  * Ported from: vtex/loaders/legacy/productList.ts
234
252
  */
235
253
  export async function legacyProductList(opts: LegacyProductListOptions): Promise<Product[] | null> {
236
- const { query, baseUrl, priceCurrency = "BRL" } = opts;
254
+ const {
255
+ query,
256
+ baseUrl,
257
+ priceCurrency = "BRL",
258
+ leanVariants,
259
+ displayedVariantId,
260
+ variantPropertyNames,
261
+ variantIncludeImage,
262
+ variantIncludeInventory,
263
+ maxImages,
264
+ priceSpecifications,
265
+ } = opts;
237
266
  const searchArgs = queryToSearchParams(query);
238
267
  const qs = buildSearchParams(searchArgs);
239
268
 
@@ -254,7 +283,17 @@ export async function legacyProductList(opts: LegacyProductListOptions): Promise
254
283
  };
255
284
 
256
285
  let products = vtexProducts.map((p) =>
257
- toProduct(p, preferredSKU(p.items), 0, { baseUrl, priceCurrency }),
286
+ toProduct(p, preferredSKU(p.items), 0, {
287
+ baseUrl,
288
+ priceCurrency,
289
+ leanVariants,
290
+ displayedVariantId,
291
+ variantPropertyNames,
292
+ variantIncludeImage,
293
+ variantIncludeInventory,
294
+ maxImages,
295
+ priceSpecifications,
296
+ }),
258
297
  );
259
298
 
260
299
  if (isSkuIdsQuery(query)) {
@@ -342,6 +381,28 @@ export interface LegacyPLPOptions {
342
381
  /** Ignore case when checking if a facet is selected */
343
382
  ignoreCaseSelected?: boolean;
344
383
  includeOriginalAttributes?: string[];
384
+ /**
385
+ * Build `isVariantOf.hasVariant[]` with the lean variant transform instead of
386
+ * a full nested `toProduct` per SKU. Off by default (unchanged behaviour).
387
+ *
388
+ * `productDetailsPage` (intelligent search) already exposes this, but the
389
+ * legacy PLP never plumbed it — so a listing always paid the full variant
390
+ * tree. Measured on a real store (36 products, 26 variants each): ~9.6 MB
391
+ * per page, 96% of each product in `isVariantOf.hasVariant`, because every
392
+ * variant carries the whole 48-entry payment ladder plus a copy of the
393
+ * parent `description` — none of which a listing card reads.
394
+ */
395
+ leanVariants?: boolean;
396
+ /** Keep the ladder on the ONE variant the card renders — see ProductOptions.displayedVariantId. */
397
+ displayedVariantId?: ProductOptions["displayedVariantId"];
398
+ /** Forwarded to the lean variant transform. */
399
+ variantPropertyNames?: Set<string>;
400
+ variantIncludeImage?: boolean;
401
+ variantIncludeInventory?: boolean;
402
+ /** Cap image[] to the first N entries — see ProductOptions.maxImages. */
403
+ maxImages?: number;
404
+ /** Rewrite priceSpecification on emitted offers — see ProductOptions.priceSpecifications. */
405
+ priceSpecifications?: ProductOptions["priceSpecifications"];
345
406
  }
346
407
 
347
408
  /**
@@ -362,6 +423,13 @@ export async function legacyProductListingPage(
362
423
  ignoreCaseSelected,
363
424
  useCollectionName,
364
425
  includeOriginalAttributes,
426
+ leanVariants,
427
+ displayedVariantId,
428
+ variantPropertyNames,
429
+ variantIncludeImage,
430
+ variantIncludeInventory,
431
+ maxImages,
432
+ priceSpecifications,
365
433
  } = opts;
366
434
 
367
435
  const currentPageOffset = opts.pageOffset ?? 1;
@@ -447,6 +515,13 @@ export async function legacyProductListingPage(
447
515
  baseUrl,
448
516
  priceCurrency,
449
517
  includeOriginalAttributes,
518
+ leanVariants,
519
+ displayedVariantId,
520
+ variantPropertyNames,
521
+ variantIncludeImage,
522
+ variantIncludeInventory,
523
+ maxImages,
524
+ priceSpecifications,
450
525
  }),
451
526
  );
452
527
 
@@ -23,6 +23,7 @@ import {
23
23
  toAdditionalPropertySpecification,
24
24
  toBrand,
25
25
  toPostalAddress,
26
+ toProduct,
26
27
  toProductShelf,
27
28
  toProductVariant,
28
29
  } from "../transform";
@@ -767,3 +768,213 @@ describe("toProductShelf", () => {
767
768
  expect(result.isVariantOf?.additionalProperty).toEqual([]);
768
769
  });
769
770
  });
771
+
772
+ // ---------------------------------------------------------------------------
773
+ // toProduct — escape hatches for leanVariants listings
774
+ // ---------------------------------------------------------------------------
775
+ //
776
+ // `leanVariants` assumes the card renders the ROOT sku, so `buildOfferVariant`
777
+ // empties `priceSpecification` on every `hasVariant[]` entry. A card that picks
778
+ // a representative variant instead (e.g. "cheapest in stock") reads that
779
+ // entry's own offer for list price and installments — blank with the ladder
780
+ // emptied. `displayedVariantId` keeps exactly that one entry full.
781
+ //
782
+ // `maxImages` caps `image[]` by POSITION. Both default to undefined, which
783
+ // preserves the previous output byte for byte — that is what the last two cases
784
+ // pin down.
785
+
786
+ describe("toProduct — displayedVariantId / maxImages", () => {
787
+ const sellers = (price: number) => [
788
+ {
789
+ sellerId: "1",
790
+ sellerName: "Seller One",
791
+ commertialOffer: {
792
+ AvailableQuantity: 5,
793
+ Price: price,
794
+ ListPrice: price + 30,
795
+ spotPrice: price,
796
+ PriceValidUntil: "2025-12-31",
797
+ // A real ladder: every payment method the store accepts, every
798
+ // installment count. Measured on a live listing: 46 rungs per SKU,
799
+ // 12.0 KB — the thing `leanOffer` exists to drop.
800
+ Installments: ["Visa", "Master", "Amex", "PIX"].flatMap((method) =>
801
+ Array.from({ length: 3 }, (_, i) => ({
802
+ Value: price / (i + 1),
803
+ NumberOfInstallments: i + 1,
804
+ Name: `${method} ${i + 1}x`,
805
+ InterestRate: 0,
806
+ TotalValuePlusInterestRate: price,
807
+ PaymentSystemName: method,
808
+ })),
809
+ ),
810
+ GiftSkuIds: [],
811
+ teasers: [],
812
+ },
813
+ },
814
+ ];
815
+
816
+ const makeSku = (itemId: string, price: number, imageCount = 4) =>
817
+ ({
818
+ itemId,
819
+ name: `SKU ${itemId}`,
820
+ ean: "1234567890123",
821
+ referenceId: [{ Key: "RefId", Value: `REF-${itemId}` }],
822
+ images: Array.from({ length: imageCount }, (_, i) => ({
823
+ imageUrl: `https://img.com/${itemId}-${i}.jpg`,
824
+ imageText: `img${i}`,
825
+ imageLabel: `label${i}`,
826
+ })),
827
+ videos: [],
828
+ sellers: sellers(price),
829
+ variations: [{ name: "Cor", values: ["Preto"] }],
830
+ kitItems: [],
831
+ complementName: "",
832
+ estimatedDateArrival: null,
833
+ modalType: null,
834
+ }) as any;
835
+
836
+ const makeProduct = (items: any[]) =>
837
+ ({
838
+ origin: "intelligent-search",
839
+ productId: "PROD1",
840
+ productName: "Test Product",
841
+ brand: "TestBrand",
842
+ brandId: 1,
843
+ brandImageUrl: null,
844
+ productReference: "REF1",
845
+ description: "desc",
846
+ releaseDate: "2024-01-01",
847
+ linkText: "test-product",
848
+ categories: ["/Electronics/"],
849
+ categoriesIds: ["/1/"],
850
+ categoryId: "1",
851
+ productClusters: [],
852
+ clusterHighlights: [],
853
+ items,
854
+ }) as any;
855
+
856
+ const options = { baseUrl: "https://example.com", priceCurrency: "BRL" };
857
+ const items = [makeSku("SKU1", 90), makeSku("SKU2", 60), makeSku("SKU3", 120)];
858
+ const product = makeProduct(items);
859
+
860
+ const ladderOf = (variant: any) => variant?.offers?.offers?.[0]?.priceSpecification ?? [];
861
+
862
+ /** Regra do caller: só os tipos base, nenhuma parcela. */
863
+ const onlyListAndSale = (specs: any[]) =>
864
+ specs.filter((s) => !s.priceComponentType);
865
+
866
+ it("leanVariants alone empties the payment ladder on every variant", () => {
867
+ const result = toProduct(product, items[0], 0, { ...options, leanVariants: true });
868
+ const variants = result.isVariantOf?.hasVariant ?? [];
869
+ expect(variants).toHaveLength(3);
870
+ for (const v of variants) expect(ladderOf(v)).toEqual([]);
871
+ });
872
+
873
+ it("displayedVariantId keeps the ladder on the ONE variant the card renders", () => {
874
+ const result = toProduct(product, items[0], 0, {
875
+ ...options,
876
+ leanVariants: true,
877
+ displayedVariantId: (skus) => skus.find((s: any) => s.itemId === "SKU2")?.itemId,
878
+ });
879
+ const variants = result.isVariantOf?.hasVariant ?? [];
880
+ const kept = variants.find((v: any) => v.sku === "SKU2");
881
+ const lean = variants.filter((v: any) => v.sku !== "SKU2");
882
+
883
+ expect(ladderOf(kept).length).toBeGreaterThan(0);
884
+ for (const v of lean) expect(ladderOf(v)).toEqual([]);
885
+ });
886
+
887
+ it("the kept variant stays LEAN — only its offer is upgraded", () => {
888
+ const result = toProduct(product, items[0], 0, {
889
+ ...options,
890
+ leanVariants: true,
891
+ displayedVariantId: () => "SKU2",
892
+ });
893
+ const kept: any = (result.isVariantOf?.hasVariant ?? []).find((v: any) => v.sku === "SKU2");
894
+
895
+ // A full toProduct here would re-emit these; the lean shape must not.
896
+ expect(kept.description).toBeUndefined();
897
+ expect(kept.brand).toBeUndefined();
898
+ expect(kept.gtin).toBeUndefined();
899
+ expect(kept.isVariantOf).toBeUndefined();
900
+ // ...but the ladder is real. Without a `priceSpecifications` rule it is
901
+ // the FULL one — dropping description/brand/isVariantOf is the win here,
902
+ // and the ladder stays whatever the caller asked for.
903
+ const full = ladderOf(toProduct(product, items[1], 0, options));
904
+ expect(ladderOf(kept).length).toBe(full.length);
905
+ });
906
+
907
+ it("the kept variant keeps its real inventoryLevel (selectors read it per SKU)", () => {
908
+ const result = toProduct(product, items[0], 0, {
909
+ ...options,
910
+ leanVariants: true,
911
+ displayedVariantId: () => "SKU2",
912
+ });
913
+ const kept: any = (result.isVariantOf?.hasVariant ?? []).find((v: any) => v.sku === "SKU2");
914
+ // buildOfferShelf hard-zeroes inventoryLevel; the variant path must not
915
+ // inherit that, or every variant reads as out of stock.
916
+ expect(kept.offers?.offers?.[0]?.inventoryLevel?.value).toBe(5);
917
+ });
918
+
919
+ it("the ladder flag does NOT leak into the lean variants", () => {
920
+ // Regression: the internal `variantKeepLadder` reached `variantOptions`,
921
+ // so every lean variant emitted a ladder instead of an empty one and the
922
+ // option made the payload BIGGER — measured on a real listing,
923
+ // isVariantOf 38.6 -> 49.6 KB.
924
+ const result = toProduct(product, items[0], 0, {
925
+ ...options,
926
+ leanVariants: true,
927
+ priceSpecifications: onlyListAndSale,
928
+ displayedVariantId: () => "SKU2",
929
+ });
930
+ const variants = result.isVariantOf?.hasVariant ?? [];
931
+ for (const v of variants.filter((v: any) => v.sku !== "SKU2")) {
932
+ expect(ladderOf(v)).toEqual([]);
933
+ }
934
+ expect(ladderOf(variants.find((v: any) => v.sku === "SKU2")).length).toBeGreaterThan(0);
935
+ });
936
+
937
+ it("priceSpecifications rewrites the ROOT ladder with the caller's own rule", () => {
938
+ const full = toProduct(product, items[0], 0, options);
939
+ const lean = toProduct(product, items[0], 0, {
940
+ ...options,
941
+ priceSpecifications: onlyListAndSale,
942
+ });
943
+
944
+ expect(ladderOf(full).length).toBeGreaterThan(ladderOf(lean).length);
945
+ const types = new Set(ladderOf(lean).map((s: any) => s.priceType));
946
+ expect(types.has("https://schema.org/ListPrice")).toBe(true);
947
+ expect(types.has("https://schema.org/SalePrice")).toBe(true);
948
+ });
949
+
950
+ it("displayedVariantId returning undefined leaves every variant lean", () => {
951
+ const result = toProduct(product, items[0], 0, {
952
+ ...options,
953
+ leanVariants: true,
954
+ displayedVariantId: () => undefined,
955
+ });
956
+ for (const v of result.isVariantOf?.hasVariant ?? []) expect(ladderOf(v)).toEqual([]);
957
+ });
958
+
959
+ it("maxImages caps image[] by position", () => {
960
+ const full = toProduct(product, items[0], 0, options);
961
+ expect(full.image).toHaveLength(4);
962
+
963
+ const capped = toProduct(product, items[0], 0, { ...options, maxImages: 2 });
964
+ expect(capped.image).toHaveLength(2);
965
+ expect(capped.image?.map((i) => i.url)).toEqual(
966
+ full.image?.slice(0, 2).map((i) => i.url),
967
+ );
968
+ });
969
+
970
+ it("every option absent is byte-for-byte the previous output", () => {
971
+ const before = toProduct(product, items[0], 0, options);
972
+ const after = toProduct(product, items[0], 0, {
973
+ ...options,
974
+ displayedVariantId: undefined,
975
+ maxImages: undefined,
976
+ priceSpecifications: undefined,
977
+ });
978
+ expect(JSON.stringify(after)).toBe(JSON.stringify(before));
979
+ });
980
+ });
@@ -99,7 +99,7 @@ const getProductURL = (origin: string, product: { linkText: string }, skuId?: st
99
99
  const nonEmptyArray = <T>(array: T[] | null | undefined) =>
100
100
  Array.isArray(array) && array.length > 0 ? array : null;
101
101
 
102
- interface ProductOptions {
102
+ export interface ProductOptions {
103
103
  baseUrl: string;
104
104
  /** Price coded currency, e.g.: USD, BRL */
105
105
  priceCurrency: string;
@@ -108,6 +108,69 @@ interface ProductOptions {
108
108
  includeOriginalAttributes?: string[];
109
109
  /** Use lean toProductVariant for hasVariant[] instead of full toProduct at level=1 */
110
110
  leanVariants?: boolean;
111
+ /**
112
+ * With `leanVariants`, keep the payment ladder on the ONE variant the card
113
+ * actually renders. Receives the raw SKU list and returns that SKU's
114
+ * `itemId` (or undefined to leave every variant lean).
115
+ *
116
+ * Why this exists: `leanVariants` assumes the card renders the ROOT sku, so
117
+ * `buildOfferVariant` empties `priceSpecification` on every entry. Cards that
118
+ * instead pick a representative variant out of `isVariantOf.hasVariant` (e.g.
119
+ * "cheapest in stock") read that variant's own offer for list price and
120
+ * installments — with the ladder emptied, those render blank.
121
+ *
122
+ * The kept entry stays on the LEAN variant shape and only its offer is
123
+ * upgraded, to {@link buildOfferShelf} (ListPrice/SalePrice/SRP + PIX + the
124
+ * one installment `useOffer` would pick). A full `toProduct` here would
125
+ * instead re-emit `description` and the whole 48-entry ladder the parent
126
+ * already carries: measured on a real listing, 17.5 KB of the 53.5 KB in
127
+ * every product, for a card that reads price and installment only.
128
+ *
129
+ * Undefined (default) preserves the current behaviour byte for byte.
130
+ */
131
+ displayedVariantId?: (items: Array<LegacySkuVTEX | SkuVTEX>) => string | undefined;
132
+ /**
133
+ * Rewrite `priceSpecification` on the offers this transform emits. Runs on
134
+ * the ROOT offer and on the offer of the variant `displayedVariantId` picks.
135
+ *
136
+ * A listing renders ONE installment string per card, while the Catalog API
137
+ * returns every payment method the store accepts: measured on a real page,
138
+ * 48 `UnitPriceSpecification` entries = 12.0 KB per product, 22% of the
139
+ * listing payload — built, cached and re-serialized in full.
140
+ *
141
+ * Deliberately a caller-supplied function and not a boolean: WHICH rungs a
142
+ * card reads is store policy, not ours. {@link buildOfferShelf} keeps the
143
+ * `bestInstallment` one (lowest total, tie-broken by highest
144
+ * `billingDuration`) — and on a store with a boleto discount that resolves to
145
+ * "Boleto Bancário 1x", measured against a storefront that renders
146
+ * "Visa 10x" on all 36 cards of the page. Same data, different string. Pass
147
+ * `(specs) => [buildOfferShelf({ ...offer, priceSpecification: specs }).…]`-style
148
+ * logic, or your own predicate; the transform only applies it.
149
+ *
150
+ * Undefined (default) keeps the full ladder — a PDP needs it.
151
+ */
152
+ priceSpecifications?: (specs: UnitPriceSpecification[]) => UnitPriceSpecification[];
153
+ /**
154
+ * Internal: set by `toProduct`/`toProductShelf` on the ONE variant
155
+ * `displayedVariantId` picked, so `toProductVariant` emits a real ladder for
156
+ * it instead of `buildOfferVariant`'s empty one. Not meant for callers.
157
+ */
158
+ variantKeepLadder?: boolean;
159
+ /**
160
+ * Cap `image[]` to the first N entries, in path order. Undefined (default)
161
+ * keeps every image.
162
+ *
163
+ * Listings render at most a couple of images per card, while the Catalog API
164
+ * returns every asset registered on the SKU (3 on average, up to 5 measured).
165
+ *
166
+ * NOTE: this truncates by POSITION, so images the consumer selects BY NAME
167
+ * can fall outside the cap. Measured on a real listing page: the `vira`
168
+ * (hover) image sits at index 1 in only 5 of the 19 products that have one —
169
+ * index 2 in 12 of them, index 3 in 2. A cap of 2 therefore drops the hover
170
+ * image on most cards that use one. Callers that select by name should keep
171
+ * the named entries instead of using this option.
172
+ */
173
+ maxImages?: number;
111
174
  /** Property names to keep on lean variant additionalProperty. Defaults to VARIANT_PROPERTY_NAMES. */
112
175
  variantPropertyNames?: Set<string>;
113
176
  /** When leanVariants is true, still include image[0] on each variant entry. Default true. */
@@ -396,17 +459,31 @@ export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
396
459
  : toAdditionalProperties(sku);
397
460
  const referenceIdAdditionalProperty = toAdditionalPropertyReferenceIds(referenceId);
398
461
  const images = nonEmptyArray(sku.images);
399
- const offers = (sku.sellers ?? []).map(isLegacyProduct(product) ? toOfferLegacy : toOffer);
400
-
401
- const variantOptions =
402
- imagesByKey !== options.imagesByKey ? { ...options, imagesByKey } : options;
462
+ const rawOffers = (sku.sellers ?? []).map(isLegacyProduct(product) ? toOfferLegacy : toOffer);
463
+ const offers = applyPriceSpecifications(rawOffers, options);
464
+
465
+ // `variantKeepLadder` must NOT be inherited: while it leaked through, every
466
+ // lean variant emitted a ladder instead of an empty one — measured on a real
467
+ // listing, `isVariantOf` GREW from 38.6 KB to 49.6 KB, so the option made
468
+ // the payload bigger. Only the entry `displayedVariantId` picks turns it on,
469
+ // explicitly, below.
470
+ const variantOptions = { ...options, imagesByKey, variantKeepLadder: false };
403
471
  const isVariantOf =
404
472
  level < 1
405
473
  ? ({
406
474
  "@type": "ProductGroup",
407
475
  productGroupID: productId,
408
476
  hasVariant: options.leanVariants
409
- ? items.map((sku) => toProductVariant(product, sku, variantOptions))
477
+ ? ((keepId) =>
478
+ items.map((sku) =>
479
+ toProductVariant(
480
+ product,
481
+ sku,
482
+ keepId !== undefined && sku.itemId === keepId
483
+ ? { ...variantOptions, variantKeepLadder: true }
484
+ : variantOptions,
485
+ ),
486
+ ))(options.displayedVariantId?.(items))
410
487
  : items.map((sku) => toProduct(product, sku, 1, variantOptions)),
411
488
  url: getProductGroupURL(baseUrl, product).href,
412
489
  name: product.productName,
@@ -418,7 +495,9 @@ export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
418
495
  } satisfies ProductGroup)
419
496
  : undefined;
420
497
 
421
- const finalImages = images?.map(({ imageUrl, imageText, imageLabel }) => {
498
+ const cappedImages =
499
+ typeof options.maxImages === "number" ? images?.slice(0, options.maxImages) : images;
500
+ const finalImages = cappedImages?.map(({ imageUrl, imageText, imageLabel }) => {
422
501
  const url = imagesByKey.get(getImageKey(imageUrl)) ?? imageUrl;
423
502
  const alternateName = imageText || imageLabel || "";
424
503
  const name = imageLabel || "";
@@ -567,6 +646,19 @@ export const buildOfferShelf = (offer: Offer): Offer => {
567
646
  };
568
647
  };
569
648
 
649
+ /**
650
+ * Apply `options.priceSpecifications` to every offer, if the caller supplied it.
651
+ * Identity (same array reference) when absent, so the default output is
652
+ * byte-for-byte unchanged.
653
+ */
654
+ const applyPriceSpecifications = (offers: Offer[], options: ProductOptions): Offer[] =>
655
+ options.priceSpecifications
656
+ ? offers.map((offer) => ({
657
+ ...offer,
658
+ priceSpecification: options.priceSpecifications!(offer.priceSpecification ?? []),
659
+ }))
660
+ : offers;
661
+
570
662
  /** Property names commonly used by ProductCard/Shelf components */
571
663
  const SHELF_PROPERTY_NAMES = new Set([
572
664
  "category",
@@ -646,9 +738,16 @@ export const toProductShelf = <P extends LegacyProductVTEX | ProductVTEX>(
646
738
  const inStockSku = findFirstAvailable(items) ?? items[0];
647
739
  // Opt-in: every SKU as a lean variant (size/color grid on shelf cards).
648
740
  // Default: a single in-stock variant (lean shelf payload).
741
+ const keepId = options.displayedVariantId?.(items);
649
742
  const hasVariant = options.shelfCompleteVariants
650
743
  ? items.map((variantSku) =>
651
- toProductVariant(product, variantSku, options),
744
+ toProductVariant(
745
+ product,
746
+ variantSku,
747
+ keepId !== undefined && variantSku.itemId === keepId
748
+ ? { ...options, variantKeepLadder: true }
749
+ : options,
750
+ ),
652
751
  )
653
752
  : inStockSku
654
753
  ? [toProductShelf(product, inStockSku, 1, options)]
@@ -741,7 +840,20 @@ export const toProductVariant = <P extends LegacyProductVTEX | ProductVTEX>(
741
840
  const offerConverter = isLegacyProduct(product) ? toOfferLegacy : toOffer;
742
841
  const allOffers = (sku.sellers ?? []).map(offerConverter).sort(bestOfferFirst);
743
842
  const bestOffer = allOffers[0];
744
- const leanOffers = bestOffer ? [buildOfferVariant(bestOffer, includeInventory)] : [];
843
+ // `variantKeepLadder` marks the ONE variant the card renders (see
844
+ // displayedVariantId): it keeps the LEAN shape but needs a real ladder,
845
+ // through the same `priceSpecifications` hook the root offer uses.
846
+ const leanOffers = bestOffer
847
+ ? [
848
+ options.variantKeepLadder
849
+ ? {
850
+ ...buildOfferVariant(bestOffer, includeInventory),
851
+ priceSpecification: applyPriceSpecifications([bestOffer], options)[0]
852
+ .priceSpecification,
853
+ }
854
+ : buildOfferVariant(bestOffer, includeInventory),
855
+ ]
856
+ : [];
745
857
 
746
858
  // image[0] only — selectors render a single thumbnail. Reuse the same
747
859
  // imagesByKey lookup toProduct uses so URLs stay consistent across variants.