@decocms/apps-vtex 7.59.0 → 7.60.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.0",
3
+ "version": "7.60.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.0",
55
- "@decocms/apps-commerce": "7.59.0",
56
- "@decocms/apps-website": "7.59.0",
57
- "@decocms/tanstack": "7.59.0"
54
+ "@decocms/blocks": "7.60.0",
55
+ "@decocms/apps-commerce": "7.60.0",
56
+ "@decocms/apps-website": "7.60.0",
57
+ "@decocms/tanstack": "7.60.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@tanstack/react-query": ">=5.0.0",
@@ -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
  // ---------------------------------------------------------------------------
@@ -342,6 +343,28 @@ export interface LegacyPLPOptions {
342
343
  /** Ignore case when checking if a facet is selected */
343
344
  ignoreCaseSelected?: boolean;
344
345
  includeOriginalAttributes?: string[];
346
+ /**
347
+ * Build `isVariantOf.hasVariant[]` with the lean variant transform instead of
348
+ * a full nested `toProduct` per SKU. Off by default (unchanged behaviour).
349
+ *
350
+ * `productDetailsPage` (intelligent search) already exposes this, but the
351
+ * legacy PLP never plumbed it — so a listing always paid the full variant
352
+ * tree. Measured on a real store (36 products, 26 variants each): ~9.6 MB
353
+ * per page, 96% of each product in `isVariantOf.hasVariant`, because every
354
+ * variant carries the whole 48-entry payment ladder plus a copy of the
355
+ * parent `description` — none of which a listing card reads.
356
+ */
357
+ leanVariants?: boolean;
358
+ /** Keep the ladder on the ONE variant the card renders — see ProductOptions.displayedVariantId. */
359
+ displayedVariantId?: ProductOptions["displayedVariantId"];
360
+ /** Forwarded to the lean variant transform. */
361
+ variantPropertyNames?: Set<string>;
362
+ variantIncludeImage?: boolean;
363
+ variantIncludeInventory?: boolean;
364
+ /** Cap image[] to the first N entries — see ProductOptions.maxImages. */
365
+ maxImages?: number;
366
+ /** Rewrite priceSpecification on emitted offers — see ProductOptions.priceSpecifications. */
367
+ priceSpecifications?: ProductOptions["priceSpecifications"];
345
368
  }
346
369
 
347
370
  /**
@@ -362,6 +385,13 @@ export async function legacyProductListingPage(
362
385
  ignoreCaseSelected,
363
386
  useCollectionName,
364
387
  includeOriginalAttributes,
388
+ leanVariants,
389
+ displayedVariantId,
390
+ variantPropertyNames,
391
+ variantIncludeImage,
392
+ variantIncludeInventory,
393
+ maxImages,
394
+ priceSpecifications,
365
395
  } = opts;
366
396
 
367
397
  const currentPageOffset = opts.pageOffset ?? 1;
@@ -447,6 +477,13 @@ export async function legacyProductListingPage(
447
477
  baseUrl,
448
478
  priceCurrency,
449
479
  includeOriginalAttributes,
480
+ leanVariants,
481
+ displayedVariantId,
482
+ variantPropertyNames,
483
+ variantIncludeImage,
484
+ variantIncludeInventory,
485
+ maxImages,
486
+ priceSpecifications,
450
487
  }),
451
488
  );
452
489
 
@@ -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.