@decocms/apps-vtex 8.0.0 → 8.1.0-next.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/__conformance__/upstream.test.ts +158 -0
- package/src/__tests__/client-set-cookie-forward.test.ts +4 -4
- package/src/client.ts +61 -4
- package/src/index.ts +17 -2
- package/src/loaders/__tests__/legacyProductList.test.ts +157 -0
- package/src/loaders/autocomplete.ts +2 -4
- package/src/loaders/intelligentSearch/productDetailsPage.ts +2 -4
- package/src/loaders/intelligentSearch/productList.ts +21 -5
- package/src/loaders/intelligentSearch/productListingPage.ts +2 -3
- package/src/loaders/intelligentSearch/suggestions.ts +2 -4
- package/src/loaders/legacy/relatedProductsLoader.ts +2 -4
- package/src/loaders/legacy.ts +77 -2
- package/src/loaders/productListFull.ts +2 -4
- package/src/loaders/workflow/products.ts +2 -4
- package/src/middleware.cacheHeaders.test.ts +44 -0
- package/src/mod.ts +18 -2
- package/src/utils/__tests__/cookieSanitizer.test.ts +1 -1
- package/src/utils/__tests__/storefrontBaseUrl.test.ts +61 -0
- package/src/utils/__tests__/transform.test.ts +349 -7
- package/src/utils/buildOfferShelf.test.ts +59 -0
- package/src/utils/enrichment.ts +3 -7
- package/src/utils/fetch.ts +5 -1
- package/src/utils/fetchCache.ts +24 -168
- package/src/utils/instrumentedFetch.ts +2 -1
- package/src/utils/minicart.ts +1 -1
- package/src/utils/operationRouter.ts +3 -9
- package/src/utils/proxy.ts +64 -4
- package/src/utils/similars.ts +2 -4
- package/src/utils/sitemap.ts +3 -2
- package/src/utils/transform.ts +222 -53
- package/src/utils/vtexId.ts +15 -2
- package/src/vtexClient.test.ts +204 -0
- package/src/vtexClient.ts +433 -0
|
@@ -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";
|
|
@@ -648,17 +649,19 @@ describe("toProductVariant", () => {
|
|
|
648
649
|
expect(offer.inventoryLevel?.value).toBe(0);
|
|
649
650
|
});
|
|
650
651
|
|
|
651
|
-
it("filters additionalProperty to variant-differentiating names only", () => {
|
|
652
|
+
it("filters spec additionalProperty to variant-differentiating names only", () => {
|
|
652
653
|
const product = makeISProduct({ items: [makeISSku()] });
|
|
653
654
|
const sku = makeISSku();
|
|
654
655
|
const result = toProductVariant(product, sku, baseOptions);
|
|
655
656
|
|
|
656
|
-
const
|
|
657
|
+
const specNames = (result.additionalProperty ?? [])
|
|
658
|
+
.filter((p) => p.valueReference !== "ReferenceID")
|
|
659
|
+
.map((p) => p.name);
|
|
657
660
|
// Should only contain Cor, Voltagem, Tamanho (from VARIANT_PROPERTY_NAMES)
|
|
658
|
-
for (const name of
|
|
661
|
+
for (const name of specNames) {
|
|
659
662
|
expect(["Cor", "Voltagem", "Tamanho"]).toContain(name);
|
|
660
663
|
}
|
|
661
|
-
expect(
|
|
664
|
+
expect(specNames.length).toBeGreaterThan(0);
|
|
662
665
|
});
|
|
663
666
|
|
|
664
667
|
it("respects custom variantPropertyNames", () => {
|
|
@@ -669,8 +672,32 @@ describe("toProductVariant", () => {
|
|
|
669
672
|
variantPropertyNames: new Set(["Cor"]),
|
|
670
673
|
});
|
|
671
674
|
|
|
672
|
-
const
|
|
673
|
-
|
|
675
|
+
const specNames = (result.additionalProperty ?? [])
|
|
676
|
+
.filter((p) => p.valueReference !== "ReferenceID")
|
|
677
|
+
.map((p) => p.name);
|
|
678
|
+
expect(specNames).toEqual(["Cor"]);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// Analytics reads the SKU RefId off additionalProperty for GA4 dimension2
|
|
682
|
+
// (view_item_list / select_item) and its productImpression / productClick
|
|
683
|
+
// equivalents. It lives in `referenceId`, not `variations`, so the
|
|
684
|
+
// variantProps filter used to drop it and blank the dimension.
|
|
685
|
+
it("keeps the SKU reference ids (RefId) alongside the variant specs", () => {
|
|
686
|
+
const product = makeISProduct({ items: [makeISSku()] });
|
|
687
|
+
const sku = { ...makeISSku(), referenceId: [{ Key: "RefId", Value: "010276949803" }] };
|
|
688
|
+
const result = toProductVariant(product, sku, baseOptions);
|
|
689
|
+
|
|
690
|
+
const refId = result.additionalProperty?.find((p) => p.name === "RefId");
|
|
691
|
+
expect(refId?.value).toBe("010276949803");
|
|
692
|
+
expect(refId?.valueReference).toBe("ReferenceID");
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("omits RefId when the SKU carries no referenceId", () => {
|
|
696
|
+
const product = makeISProduct({ items: [makeISSku()] });
|
|
697
|
+
const sku = { ...makeISSku(), referenceId: undefined };
|
|
698
|
+
const result = toProductVariant(product, sku, baseOptions);
|
|
699
|
+
|
|
700
|
+
expect(result.additionalProperty?.some((p) => p.name === "RefId")).toBe(false);
|
|
674
701
|
});
|
|
675
702
|
|
|
676
703
|
it("produces lean offers with availability but no priceSpecification details", () => {
|
|
@@ -722,10 +749,11 @@ describe("toProductShelf", () => {
|
|
|
722
749
|
},
|
|
723
750
|
],
|
|
724
751
|
variations: [{ name: "Cor", values: ["Preto"] }],
|
|
752
|
+
...overrides,
|
|
725
753
|
}) as any;
|
|
726
754
|
|
|
727
755
|
// Campanha is a PRODUCT specification (in specificationGroups), NOT a sku.variation.
|
|
728
|
-
// It is the marker
|
|
756
|
+
// It is the marker one storefront uses to tag launches ("Lançamentos").
|
|
729
757
|
const makeProduct = (overrides: Record<string, unknown> = {}) =>
|
|
730
758
|
({
|
|
731
759
|
origin: "intelligent-search",
|
|
@@ -766,4 +794,318 @@ describe("toProductShelf", () => {
|
|
|
766
794
|
);
|
|
767
795
|
expect(result.isVariantOf?.additionalProperty).toEqual([]);
|
|
768
796
|
});
|
|
797
|
+
|
|
798
|
+
// Analytics reads the product reference off isVariantOf.model for GA4
|
|
799
|
+
// dimension1 (view_item_list / select_item) and its productImpression /
|
|
800
|
+
// productClick equivalents. toProductShelf used to drop it, blanking the
|
|
801
|
+
// dimension on every shelf, PLP and search result.
|
|
802
|
+
it("carries productReference on isVariantOf.model (analytics dimension1)", () => {
|
|
803
|
+
const result = toProductShelf(
|
|
804
|
+
makeProduct({ productReference: "0102769498" }),
|
|
805
|
+
makeSku(),
|
|
806
|
+
0,
|
|
807
|
+
options,
|
|
808
|
+
);
|
|
809
|
+
expect(result.isVariantOf?.model).toBe("0102769498");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("keeps the SKU RefId reachable on the complete-variants shelf (dimension2)", () => {
|
|
813
|
+
const result = toProductShelf(makeProduct(), makeSku(), 0, {
|
|
814
|
+
...options,
|
|
815
|
+
shelfCompleteVariants: true,
|
|
816
|
+
});
|
|
817
|
+
const variantProps = result.isVariantOf?.hasVariant?.[0]?.additionalProperty ?? [];
|
|
818
|
+
expect(variantProps).toEqual(
|
|
819
|
+
expect.arrayContaining([expect.objectContaining({ name: "RefId", value: "REF-SKU1" })]),
|
|
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
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// ---------------------------------------------------------------------------
|
|
888
|
+
// toProduct — escape hatches for leanVariants listings
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
//
|
|
891
|
+
// `leanVariants` assumes the card renders the ROOT sku, so `buildOfferVariant`
|
|
892
|
+
// empties `priceSpecification` on every `hasVariant[]` entry. A card that picks
|
|
893
|
+
// a representative variant instead (e.g. "cheapest in stock") reads that
|
|
894
|
+
// entry's own offer for list price and installments — blank with the ladder
|
|
895
|
+
// emptied. `displayedVariantId` keeps exactly that one entry full.
|
|
896
|
+
//
|
|
897
|
+
// `maxImages` caps `image[]` by POSITION. Both default to undefined, which
|
|
898
|
+
// preserves the previous output byte for byte — that is what the last two cases
|
|
899
|
+
// pin down.
|
|
900
|
+
|
|
901
|
+
describe("toProduct — displayedVariantId / maxImages", () => {
|
|
902
|
+
const sellers = (price: number) => [
|
|
903
|
+
{
|
|
904
|
+
sellerId: "1",
|
|
905
|
+
sellerName: "Seller One",
|
|
906
|
+
commertialOffer: {
|
|
907
|
+
AvailableQuantity: 5,
|
|
908
|
+
Price: price,
|
|
909
|
+
ListPrice: price + 30,
|
|
910
|
+
spotPrice: price,
|
|
911
|
+
PriceValidUntil: "2025-12-31",
|
|
912
|
+
// A real ladder: every payment method the store accepts, every
|
|
913
|
+
// installment count. Measured on a live listing: 46 rungs per SKU,
|
|
914
|
+
// 12.0 KB — the thing `leanOffer` exists to drop.
|
|
915
|
+
Installments: ["Visa", "Master", "Amex", "PIX"].flatMap((method) =>
|
|
916
|
+
Array.from({ length: 3 }, (_, i) => ({
|
|
917
|
+
Value: price / (i + 1),
|
|
918
|
+
NumberOfInstallments: i + 1,
|
|
919
|
+
Name: `${method} ${i + 1}x`,
|
|
920
|
+
InterestRate: 0,
|
|
921
|
+
TotalValuePlusInterestRate: price,
|
|
922
|
+
PaymentSystemName: method,
|
|
923
|
+
})),
|
|
924
|
+
),
|
|
925
|
+
GiftSkuIds: [],
|
|
926
|
+
teasers: [],
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
];
|
|
930
|
+
|
|
931
|
+
const makeSku = (itemId: string, price: number, imageCount = 4) =>
|
|
932
|
+
({
|
|
933
|
+
itemId,
|
|
934
|
+
name: `SKU ${itemId}`,
|
|
935
|
+
ean: "1234567890123",
|
|
936
|
+
referenceId: [{ Key: "RefId", Value: `REF-${itemId}` }],
|
|
937
|
+
images: Array.from({ length: imageCount }, (_, i) => ({
|
|
938
|
+
imageUrl: `https://img.com/${itemId}-${i}.jpg`,
|
|
939
|
+
imageText: `img${i}`,
|
|
940
|
+
imageLabel: `label${i}`,
|
|
941
|
+
})),
|
|
942
|
+
videos: [],
|
|
943
|
+
sellers: sellers(price),
|
|
944
|
+
variations: [{ name: "Cor", values: ["Preto"] }],
|
|
945
|
+
kitItems: [],
|
|
946
|
+
complementName: "",
|
|
947
|
+
estimatedDateArrival: null,
|
|
948
|
+
modalType: null,
|
|
949
|
+
}) as any;
|
|
950
|
+
|
|
951
|
+
const makeProduct = (items: any[]) =>
|
|
952
|
+
({
|
|
953
|
+
origin: "intelligent-search",
|
|
954
|
+
productId: "PROD1",
|
|
955
|
+
productName: "Test Product",
|
|
956
|
+
brand: "TestBrand",
|
|
957
|
+
brandId: 1,
|
|
958
|
+
brandImageUrl: null,
|
|
959
|
+
productReference: "REF1",
|
|
960
|
+
description: "desc",
|
|
961
|
+
releaseDate: "2024-01-01",
|
|
962
|
+
linkText: "test-product",
|
|
963
|
+
categories: ["/Electronics/"],
|
|
964
|
+
categoriesIds: ["/1/"],
|
|
965
|
+
categoryId: "1",
|
|
966
|
+
productClusters: [],
|
|
967
|
+
clusterHighlights: [],
|
|
968
|
+
items,
|
|
969
|
+
}) as any;
|
|
970
|
+
|
|
971
|
+
const options = { baseUrl: "https://example.com", priceCurrency: "BRL" };
|
|
972
|
+
const items = [makeSku("SKU1", 90), makeSku("SKU2", 60), makeSku("SKU3", 120)];
|
|
973
|
+
const product = makeProduct(items);
|
|
974
|
+
|
|
975
|
+
const ladderOf = (variant: any) => variant?.offers?.offers?.[0]?.priceSpecification ?? [];
|
|
976
|
+
|
|
977
|
+
/** Regra do caller: só os tipos base, nenhuma parcela. */
|
|
978
|
+
const onlyListAndSale = (specs: any[]) =>
|
|
979
|
+
specs.filter((s) => !s.priceComponentType);
|
|
980
|
+
|
|
981
|
+
it("leanVariants alone empties the payment ladder on every variant", () => {
|
|
982
|
+
const result = toProduct(product, items[0], 0, { ...options, leanVariants: true });
|
|
983
|
+
const variants = result.isVariantOf?.hasVariant ?? [];
|
|
984
|
+
expect(variants).toHaveLength(3);
|
|
985
|
+
for (const v of variants) expect(ladderOf(v)).toEqual([]);
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it("displayedVariantId keeps the ladder on the ONE variant the card renders", () => {
|
|
989
|
+
const result = toProduct(product, items[0], 0, {
|
|
990
|
+
...options,
|
|
991
|
+
leanVariants: true,
|
|
992
|
+
displayedVariantId: (skus) => skus.find((s: any) => s.itemId === "SKU2")?.itemId,
|
|
993
|
+
});
|
|
994
|
+
const variants = result.isVariantOf?.hasVariant ?? [];
|
|
995
|
+
const kept = variants.find((v: any) => v.sku === "SKU2");
|
|
996
|
+
const lean = variants.filter((v: any) => v.sku !== "SKU2");
|
|
997
|
+
|
|
998
|
+
expect(ladderOf(kept).length).toBeGreaterThan(0);
|
|
999
|
+
for (const v of lean) expect(ladderOf(v)).toEqual([]);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("the kept variant stays LEAN — only its offer is upgraded", () => {
|
|
1003
|
+
const result = toProduct(product, items[0], 0, {
|
|
1004
|
+
...options,
|
|
1005
|
+
leanVariants: true,
|
|
1006
|
+
displayedVariantId: () => "SKU2",
|
|
1007
|
+
});
|
|
1008
|
+
const kept: any = (result.isVariantOf?.hasVariant ?? []).find((v: any) => v.sku === "SKU2");
|
|
1009
|
+
|
|
1010
|
+
// A full toProduct here would re-emit these; the lean shape must not.
|
|
1011
|
+
expect(kept.description).toBeUndefined();
|
|
1012
|
+
expect(kept.brand).toBeUndefined();
|
|
1013
|
+
expect(kept.gtin).toBeUndefined();
|
|
1014
|
+
expect(kept.isVariantOf).toBeUndefined();
|
|
1015
|
+
// ...but the ladder is real. Without a `priceSpecifications` rule it is
|
|
1016
|
+
// the FULL one — dropping description/brand/isVariantOf is the win here,
|
|
1017
|
+
// and the ladder stays whatever the caller asked for.
|
|
1018
|
+
const full = ladderOf(toProduct(product, items[1], 0, options));
|
|
1019
|
+
expect(ladderOf(kept).length).toBe(full.length);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it("the kept variant keeps its real inventoryLevel (selectors read it per SKU)", () => {
|
|
1023
|
+
const result = toProduct(product, items[0], 0, {
|
|
1024
|
+
...options,
|
|
1025
|
+
leanVariants: true,
|
|
1026
|
+
displayedVariantId: () => "SKU2",
|
|
1027
|
+
});
|
|
1028
|
+
const kept: any = (result.isVariantOf?.hasVariant ?? []).find((v: any) => v.sku === "SKU2");
|
|
1029
|
+
// buildOfferShelf hard-zeroes inventoryLevel; the variant path must not
|
|
1030
|
+
// inherit that, or every variant reads as out of stock.
|
|
1031
|
+
expect(kept.offers?.offers?.[0]?.inventoryLevel?.value).toBe(5);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("the ladder flag does NOT leak into the lean variants", () => {
|
|
1035
|
+
// Regression: the internal `variantKeepLadder` reached `variantOptions`,
|
|
1036
|
+
// so every lean variant emitted a ladder instead of an empty one and the
|
|
1037
|
+
// option made the payload BIGGER — measured on a real listing,
|
|
1038
|
+
// isVariantOf 38.6 -> 49.6 KB.
|
|
1039
|
+
const result = toProduct(product, items[0], 0, {
|
|
1040
|
+
...options,
|
|
1041
|
+
leanVariants: true,
|
|
1042
|
+
priceSpecifications: onlyListAndSale,
|
|
1043
|
+
displayedVariantId: () => "SKU2",
|
|
1044
|
+
});
|
|
1045
|
+
const variants = result.isVariantOf?.hasVariant ?? [];
|
|
1046
|
+
for (const v of variants.filter((v: any) => v.sku !== "SKU2")) {
|
|
1047
|
+
expect(ladderOf(v)).toEqual([]);
|
|
1048
|
+
}
|
|
1049
|
+
expect(ladderOf(variants.find((v: any) => v.sku === "SKU2")).length).toBeGreaterThan(0);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it("priceSpecifications rewrites the ROOT ladder with the caller's own rule", () => {
|
|
1053
|
+
const full = toProduct(product, items[0], 0, options);
|
|
1054
|
+
const lean = toProduct(product, items[0], 0, {
|
|
1055
|
+
...options,
|
|
1056
|
+
priceSpecifications: onlyListAndSale,
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
expect(ladderOf(full).length).toBeGreaterThan(ladderOf(lean).length);
|
|
1060
|
+
const types = new Set(ladderOf(lean).map((s: any) => s.priceType));
|
|
1061
|
+
expect(types.has("https://schema.org/ListPrice")).toBe(true);
|
|
1062
|
+
expect(types.has("https://schema.org/SalePrice")).toBe(true);
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it("displayedVariantId returning undefined leaves every variant lean", () => {
|
|
1066
|
+
const result = toProduct(product, items[0], 0, {
|
|
1067
|
+
...options,
|
|
1068
|
+
leanVariants: true,
|
|
1069
|
+
displayedVariantId: () => undefined,
|
|
1070
|
+
});
|
|
1071
|
+
for (const v of result.isVariantOf?.hasVariant ?? []) expect(ladderOf(v)).toEqual([]);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it("maxImages caps image[] by position", () => {
|
|
1075
|
+
const full = toProduct(product, items[0], 0, options);
|
|
1076
|
+
expect(full.image).toHaveLength(4);
|
|
1077
|
+
|
|
1078
|
+
const capped = toProduct(product, items[0], 0, { ...options, maxImages: 2 });
|
|
1079
|
+
expect(capped.image).toHaveLength(2);
|
|
1080
|
+
expect(capped.image?.map((i) => i.url)).toEqual(
|
|
1081
|
+
full.image?.slice(0, 2).map((i) => i.url),
|
|
1082
|
+
);
|
|
1083
|
+
});
|
|
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
|
+
|
|
1100
|
+
it("every option absent is byte-for-byte the previous output", () => {
|
|
1101
|
+
const before = toProduct(product, items[0], 0, options);
|
|
1102
|
+
const after = toProduct(product, items[0], 0, {
|
|
1103
|
+
...options,
|
|
1104
|
+
displayedVariantId: undefined,
|
|
1105
|
+
maxImages: undefined,
|
|
1106
|
+
keepImageNames: undefined,
|
|
1107
|
+
priceSpecifications: undefined,
|
|
1108
|
+
});
|
|
1109
|
+
expect(JSON.stringify(after)).toBe(JSON.stringify(before));
|
|
1110
|
+
});
|
|
769
1111
|
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { AggregateOffer, Offer, UnitPriceSpecification } from "@decocms/apps-commerce/types";
|
|
3
|
+
import { useOffer } from "@decocms/apps-commerce/sdk/useOffer";
|
|
4
|
+
import { buildOfferShelf } from "./transform";
|
|
5
|
+
|
|
6
|
+
const installment = (
|
|
7
|
+
over: Partial<UnitPriceSpecification>,
|
|
8
|
+
): UnitPriceSpecification =>
|
|
9
|
+
({
|
|
10
|
+
"@type": "UnitPriceSpecification",
|
|
11
|
+
priceType: "https://schema.org/SalePrice",
|
|
12
|
+
priceComponentType: "https://schema.org/Installment",
|
|
13
|
+
...over,
|
|
14
|
+
}) as UnitPriceSpecification;
|
|
15
|
+
|
|
16
|
+
const offerWith = (specs: UnitPriceSpecification[]): Offer =>
|
|
17
|
+
({
|
|
18
|
+
"@type": "Offer",
|
|
19
|
+
price: 100,
|
|
20
|
+
priceSpecification: [
|
|
21
|
+
{ "@type": "UnitPriceSpecification", priceType: "https://schema.org/ListPrice", price: 120 },
|
|
22
|
+
{ "@type": "UnitPriceSpecification", priceType: "https://schema.org/SalePrice", price: 100 },
|
|
23
|
+
...specs,
|
|
24
|
+
],
|
|
25
|
+
}) as Offer;
|
|
26
|
+
|
|
27
|
+
const pickInstallment = (offer: Offer) =>
|
|
28
|
+
useOffer({ "@type": "AggregateOffer", offers: [offer] } as AggregateOffer).installment;
|
|
29
|
+
|
|
30
|
+
describe("buildOfferShelf installment selection", () => {
|
|
31
|
+
it("keeps the same installment useOffer picks on PLP/PDP (shelf == detail)", () => {
|
|
32
|
+
// 12x whose stated total (100) is the lowest and duration the highest, but VTEX
|
|
33
|
+
// rounding makes 12 * 8.34 = 100.08 — the old 1-cent isNoInterest check rejected
|
|
34
|
+
// it and the shelf fell back to 10x, diverging from the detail page's 12x.
|
|
35
|
+
const full = offerWith([
|
|
36
|
+
installment({ name: "PIX", billingDuration: 1, billingIncrement: 100, price: 100 }),
|
|
37
|
+
installment({ name: "Visa", billingDuration: 10, billingIncrement: 10, price: 100 }),
|
|
38
|
+
installment({ name: "Visa", billingDuration: 12, billingIncrement: 8.34, price: 100 }),
|
|
39
|
+
installment({ name: "Visa", billingDuration: 6, billingIncrement: 17.67, price: 106 }),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const lean = buildOfferShelf(full);
|
|
43
|
+
|
|
44
|
+
expect(pickInstallment(lean)).toEqual(pickInstallment(full));
|
|
45
|
+
expect(pickInstallment(lean)?.billingDuration).toBe(12);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps the lowest-total plan even when every installment has interest", () => {
|
|
49
|
+
const full = offerWith([
|
|
50
|
+
installment({ name: "Visa", billingDuration: 3, billingIncrement: 34, price: 102 }),
|
|
51
|
+
installment({ name: "Visa", billingDuration: 6, billingIncrement: 18, price: 108 }),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const lean = buildOfferShelf(full);
|
|
55
|
+
|
|
56
|
+
expect(pickInstallment(lean)).toEqual(pickInstallment(full));
|
|
57
|
+
expect(pickInstallment(lean)?.price).toBe(102);
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/utils/enrichment.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import type { Product, ProductLeaf } from "@decocms/apps-commerce/types";
|
|
27
|
-
import { getVtexConfig, vtexFetch, vtexIOGraphQL } from "../client";
|
|
27
|
+
import { getVtexConfig, storefrontBaseUrl, vtexFetch, vtexIOGraphQL } from "../client";
|
|
28
28
|
import { listBrands } from "../loaders/brands";
|
|
29
29
|
import { batch } from "./batch";
|
|
30
30
|
import { withIsSimilarTo } from "./similars";
|
|
@@ -372,9 +372,7 @@ export function withKitItems(): ProductEnricher {
|
|
|
372
372
|
if (!productIDs.size) return products;
|
|
373
373
|
|
|
374
374
|
const config = getVtexConfig();
|
|
375
|
-
const baseUrl = config
|
|
376
|
-
? `https://${config.publicUrl}`
|
|
377
|
-
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
375
|
+
const baseUrl = storefrontBaseUrl(config);
|
|
378
376
|
|
|
379
377
|
const batches = batch([...productIDs], KIT_ITEMS_BATCH_SIZE);
|
|
380
378
|
const productsById = new Map<string, ProductLeaf>();
|
|
@@ -428,9 +426,7 @@ export function withVariants(): ProductEnricher {
|
|
|
428
426
|
if (!productIDs.size) return products;
|
|
429
427
|
|
|
430
428
|
const config = getVtexConfig();
|
|
431
|
-
const baseUrl = config
|
|
432
|
-
? `https://${config.publicUrl}`
|
|
433
|
-
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
429
|
+
const baseUrl = storefrontBaseUrl(config);
|
|
434
430
|
|
|
435
431
|
const batches = batch([...productIDs], VARIANTS_BATCH_SIZE);
|
|
436
432
|
const productsById = new Map<string, Product>();
|
package/src/utils/fetch.ts
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* the platform.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";
|
|
19
|
+
|
|
20
|
+
const timeoutFetch = withFetchTimeout();
|
|
21
|
+
|
|
18
22
|
type CachingMode = "stale-while-revalidate";
|
|
19
23
|
|
|
20
24
|
type DecoInit = {
|
|
@@ -88,7 +92,7 @@ export async function fetchSafe(
|
|
|
88
92
|
init?: DecoRequestInit,
|
|
89
93
|
): Promise<Response> {
|
|
90
94
|
const sanitized = sanitizeUrl(input);
|
|
91
|
-
const response = await
|
|
95
|
+
const response = await timeoutFetch(sanitized as RequestInfo, init);
|
|
92
96
|
if (!response.ok) {
|
|
93
97
|
throw new HttpError(response);
|
|
94
98
|
}
|