@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 +5 -5
- package/src/loaders/legacy.ts +37 -0
- package/src/utils/__tests__/transform.test.ts +211 -0
- package/src/utils/transform.ts +121 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-vtex",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
55
|
-
"@decocms/apps-commerce": "7.
|
|
56
|
-
"@decocms/apps-website": "7.
|
|
57
|
-
"@decocms/tanstack": "7.
|
|
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",
|
package/src/loaders/legacy.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/utils/transform.ts
CHANGED
|
@@ -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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
?
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|