@decocms/apps-vtex 7.66.5 → 7.66.6

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.66.5",
3
+ "version": "7.66.6",
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.66.5",
55
- "@decocms/apps-commerce": "7.66.5",
56
- "@decocms/apps-website": "7.66.5",
57
- "@decocms/tanstack": "7.66.5"
54
+ "@decocms/blocks": "7.66.6",
55
+ "@decocms/apps-commerce": "7.66.6",
56
+ "@decocms/apps-website": "7.66.6",
57
+ "@decocms/tanstack": "7.66.6"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@tanstack/react-query": ">=5.0.0",
@@ -19,6 +19,13 @@ export interface ProductListProps {
19
19
  * a single in-stock variant per product).
20
20
  */
21
21
  completeVariants?: boolean;
22
+ /**
23
+ * `imageLabel`s that survive the shelf transform's 2-image cap. Storefronts
24
+ * that pick the card image by name (a still/packshot asset, a colour
25
+ * thumbnail) need this: those assets are registered last on the SKU, so the
26
+ * positional cap drops them and the card falls back to the first photo.
27
+ */
28
+ keepImageNames?: string[];
22
29
  }
23
30
 
24
31
  interface CollectionProps {
@@ -147,6 +154,7 @@ export default async function vtexProductListShelf(
147
154
  baseUrl,
148
155
  priceCurrency: "BRL",
149
156
  shelfCompleteVariants: props.completeVariants,
157
+ keepImageNames: props.keepImageNames,
150
158
  });
151
159
  });
152
160
 
@@ -749,6 +749,7 @@ describe("toProductShelf", () => {
749
749
  },
750
750
  ],
751
751
  variations: [{ name: "Cor", values: ["Preto"] }],
752
+ ...overrides,
752
753
  }) as any;
753
754
 
754
755
  // Campanha is a PRODUCT specification (in specificationGroups), NOT a sku.variation.
@@ -818,6 +819,69 @@ describe("toProductShelf", () => {
818
819
  expect.arrayContaining([expect.objectContaining({ name: "RefId", value: "REF-SKU1" })]),
819
820
  );
820
821
  });
822
+
823
+ // A real catalog registers the packshot and the colour thumbnail LAST:
824
+ // measured on a live store, `secondary` sits at index 6 and `color-thumbnail`
825
+ // at index 7 of 8. A card that selects either BY NAME gets nothing from a
826
+ // positional cap and silently falls back to the first photo (the model
827
+ // wearing the piece).
828
+ const labelledImages = [
829
+ ...Array.from({ length: 6 }, (_, i) => ({
830
+ imageUrl: `https://img.com/photo-${i}.jpg`,
831
+ imageText: `Photo ${i}`,
832
+ imageLabel: `product-photo-${i}`,
833
+ })),
834
+ { imageUrl: "https://img.com/still.jpg", imageText: "Still", imageLabel: "secondary" },
835
+ { imageUrl: "https://img.com/thumb.jpg", imageText: "Thumb", imageLabel: "color-thumbnail" },
836
+ ];
837
+ const skuWithLabels = () => makeSku({ images: labelledImages });
838
+ const labelledProduct = () => makeProduct({ items: [skuWithLabels()] });
839
+
840
+ it("keeps the 2-image cap when keepImageNames is absent", () => {
841
+ const result = toProductShelf(labelledProduct(), skuWithLabels(), 0, options);
842
+ expect(result.image?.map((i) => i.name)).toEqual(["product-photo-0", "product-photo-1"]);
843
+ });
844
+
845
+ it("keepImageNames adds back labelled images that fall outside the cap", () => {
846
+ const result = toProductShelf(labelledProduct(), skuWithLabels(), 0, {
847
+ ...options,
848
+ keepImageNames: ["secondary", "color-thumbnail"],
849
+ });
850
+ expect(result.image?.map((i) => i.name)).toEqual([
851
+ "product-photo-0",
852
+ "product-photo-1",
853
+ "secondary",
854
+ "color-thumbnail",
855
+ ]);
856
+ });
857
+
858
+ it("keepImageNames does not duplicate a label already inside the cap", () => {
859
+ const result = toProductShelf(labelledProduct(), skuWithLabels(), 0, {
860
+ ...options,
861
+ keepImageNames: ["product-photo-0", "secondary"],
862
+ });
863
+ expect(result.image?.map((i) => i.name)).toEqual([
864
+ "product-photo-0",
865
+ "product-photo-1",
866
+ "secondary",
867
+ ]);
868
+ });
869
+
870
+ it("keepImageNames listing a label the SKU does not have changes nothing", () => {
871
+ const result = toProductShelf(labelledProduct(), skuWithLabels(), 0, {
872
+ ...options,
873
+ keepImageNames: ["vira"],
874
+ });
875
+ expect(result.image?.map((i) => i.name)).toEqual(["product-photo-0", "product-photo-1"]);
876
+ });
877
+
878
+ it("maxImages overrides the shelf default cap", () => {
879
+ const result = toProductShelf(labelledProduct(), skuWithLabels(), 0, {
880
+ ...options,
881
+ maxImages: 3,
882
+ });
883
+ expect(result.image).toHaveLength(3);
884
+ });
821
885
  });
822
886
 
823
887
  // ---------------------------------------------------------------------------
@@ -1018,12 +1082,28 @@ describe("toProduct — displayedVariantId / maxImages", () => {
1018
1082
  );
1019
1083
  });
1020
1084
 
1085
+ it("keepImageNames rescues a labelled image from the maxImages cap", () => {
1086
+ const capped = toProduct(product, items[0], 0, {
1087
+ ...options,
1088
+ maxImages: 2,
1089
+ keepImageNames: ["label3"],
1090
+ });
1091
+ expect(capped.image?.map((i) => i.name)).toEqual(["label0", "label1", "label3"]);
1092
+ });
1093
+
1094
+ it("keepImageNames without maxImages is a no-op — every image is already there", () => {
1095
+ const before = toProduct(product, items[0], 0, options);
1096
+ const after = toProduct(product, items[0], 0, { ...options, keepImageNames: ["label3"] });
1097
+ expect(JSON.stringify(after)).toBe(JSON.stringify(before));
1098
+ });
1099
+
1021
1100
  it("every option absent is byte-for-byte the previous output", () => {
1022
1101
  const before = toProduct(product, items[0], 0, options);
1023
1102
  const after = toProduct(product, items[0], 0, {
1024
1103
  ...options,
1025
1104
  displayedVariantId: undefined,
1026
1105
  maxImages: undefined,
1106
+ keepImageNames: undefined,
1027
1107
  priceSpecifications: undefined,
1028
1108
  });
1029
1109
  expect(JSON.stringify(after)).toBe(JSON.stringify(before));
@@ -168,9 +168,24 @@ export interface ProductOptions {
168
168
  * (hover) image sits at index 1 in only 5 of the 19 products that have one —
169
169
  * index 2 in 12 of them, index 3 in 2. A cap of 2 therefore drops the hover
170
170
  * image on most cards that use one. Callers that select by name should keep
171
- * the named entries instead of using this option.
171
+ * the named entries through {@link keepImageNames}.
172
172
  */
173
173
  maxImages?: number;
174
+ /**
175
+ * `imageLabel`s that survive the positional cap. An image whose label is
176
+ * listed here is kept even when it falls outside {@link maxImages} (and
177
+ * outside the shelf transform's own default cap), in its original order.
178
+ *
179
+ * This is the escape hatch the `maxImages` note asks for. A storefront that
180
+ * picks the card image BY NAME — a "still"/packshot asset, a colour
181
+ * thumbnail — cannot use a positional cap on its own: those assets are
182
+ * registered last on the SKU, so any cap drops them and the card silently
183
+ * falls back to the first photo. Pass the labels the card selects and the
184
+ * payload stays lean everywhere else.
185
+ *
186
+ * Undefined (default) preserves the current behaviour byte for byte.
187
+ */
188
+ keepImageNames?: string[];
174
189
  /** Property names to keep on lean variant additionalProperty. Defaults to VARIANT_PROPERTY_NAMES. */
175
190
  variantPropertyNames?: Set<string>;
176
191
  /** When leanVariants is true, still include image[0] on each variant entry. Default true. */
@@ -186,6 +201,28 @@ export interface ProductOptions {
186
201
  shelfCompleteVariants?: boolean;
187
202
  }
188
203
 
204
+ /**
205
+ * Apply the positional image cap, then add back the entries the caller selects
206
+ * by name. Order is preserved: the capped head first, then the named tail in
207
+ * catalog order. No cap means no work.
208
+ */
209
+ const capImages = <I extends { imageLabel?: string | null }>(
210
+ images: I[] | null | undefined,
211
+ maxImages: number | undefined,
212
+ keepImageNames: string[] | undefined,
213
+ ): I[] | null | undefined => {
214
+ if (!images || typeof maxImages !== "number") return images;
215
+
216
+ const capped = images.slice(0, maxImages);
217
+
218
+ if (!keepImageNames?.length) return capped;
219
+
220
+ const keep = new Set(keepImageNames);
221
+ const named = images.slice(maxImages).filter((image) => keep.has(image.imageLabel ?? ""));
222
+
223
+ return named.length > 0 ? [...capped, ...named] : capped;
224
+ };
225
+
189
226
  /** Returns first available sku */
190
227
  const findFirstAvailable = (items: Array<LegacySkuVTEX | SkuVTEX>) =>
191
228
  items?.find((item) =>
@@ -495,8 +532,7 @@ export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
495
532
  } satisfies ProductGroup)
496
533
  : undefined;
497
534
 
498
- const cappedImages =
499
- typeof options.maxImages === "number" ? images?.slice(0, options.maxImages) : images;
535
+ const cappedImages = capImages(images, options.maxImages, options.keepImageNames);
500
536
  const finalImages = cappedImages?.map(({ imageUrl, imageText, imageLabel }) => {
501
537
  const url = imagesByKey.get(getImageKey(imageUrl)) ?? imageUrl;
502
538
  const alternateName = imageText || imageLabel || "";
@@ -659,6 +695,9 @@ const applyPriceSpecifications = (offers: Offer[], options: ProductOptions): Off
659
695
  }))
660
696
  : offers;
661
697
 
698
+ /** Default positional image cap for the shelf transform (front + back). */
699
+ const SHELF_MAX_IMAGES = 2;
700
+
662
701
  /** Property names commonly used by ProductCard/Shelf components */
663
702
  const SHELF_PROPERTY_NAMES = new Set([
664
703
  "category",
@@ -674,7 +713,8 @@ const SHELF_PROPERTY_NAMES = new Set([
674
713
  * Lean product transform for shelf/card display. Same signature as toProduct().
675
714
  *
676
715
  * Differences from toProduct():
677
- * - Images: capped at 2 per SKU (front + back)
716
+ * - Images: capped at 2 per SKU (front + back), overridable via `maxImages`;
717
+ * `keepImageNames` adds back labelled assets that fall outside the cap
678
718
  * - Offers: best seller only (in-stock first, then cheapest), stripped installments (keeps ListPrice, SalePrice, SRP, PIX, best no-interest)
679
719
  * - isVariantOf: single in-stock variant at level 0
680
720
  * - additionalProperty: filtered to known-used property names
@@ -690,9 +730,11 @@ export const toProductShelf = <P extends LegacyProductVTEX | ProductVTEX>(
690
730
  const { productId, items, productReference } = product;
691
731
  const { name, itemId: skuId } = sku;
692
732
 
693
- // Images: cap at 2
733
+ // Images: cap at 2, keeping any label the caller selects by name
694
734
  const rawImages = nonEmptyArray(sku.images);
695
- const mappedImages = (rawImages ?? []).slice(0, 2).map(({ imageUrl, imageText, imageLabel }) => ({
735
+ const cappedImages =
736
+ capImages(rawImages, options.maxImages ?? SHELF_MAX_IMAGES, options.keepImageNames) ?? [];
737
+ const mappedImages = cappedImages.map(({ imageUrl, imageText, imageLabel }) => ({
696
738
  "@type": "ImageObject" as const,
697
739
  alternateName: imageText || imageLabel || "",
698
740
  url: imageUrl,