@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
package/src/utils/transform.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
SiteNavigationElement,
|
|
21
21
|
UnitPriceSpecification,
|
|
22
22
|
} from "@decocms/apps-commerce/types";
|
|
23
|
+
import { bestInstallment } from "@decocms/apps-commerce/sdk/useOffer";
|
|
23
24
|
import { DEFAULT_IMAGE } from "@decocms/apps-commerce/utils/constants";
|
|
24
25
|
import { formatRange } from "@decocms/apps-commerce/utils/filters";
|
|
25
26
|
import { pick } from "./pickAndOmit";
|
|
@@ -98,7 +99,7 @@ const getProductURL = (origin: string, product: { linkText: string }, skuId?: st
|
|
|
98
99
|
const nonEmptyArray = <T>(array: T[] | null | undefined) =>
|
|
99
100
|
Array.isArray(array) && array.length > 0 ? array : null;
|
|
100
101
|
|
|
101
|
-
interface ProductOptions {
|
|
102
|
+
export interface ProductOptions {
|
|
102
103
|
baseUrl: string;
|
|
103
104
|
/** Price coded currency, e.g.: USD, BRL */
|
|
104
105
|
priceCurrency: string;
|
|
@@ -107,14 +108,121 @@ interface ProductOptions {
|
|
|
107
108
|
includeOriginalAttributes?: string[];
|
|
108
109
|
/** Use lean toProductVariant for hasVariant[] instead of full toProduct at level=1 */
|
|
109
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 through {@link keepImageNames}.
|
|
172
|
+
*/
|
|
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[];
|
|
110
189
|
/** Property names to keep on lean variant additionalProperty. Defaults to VARIANT_PROPERTY_NAMES. */
|
|
111
190
|
variantPropertyNames?: Set<string>;
|
|
112
191
|
/** When leanVariants is true, still include image[0] on each variant entry. Default true. */
|
|
113
192
|
variantIncludeImage?: boolean;
|
|
114
193
|
/** When leanVariants is true, still include inventoryLevel on each variant offer. Default true. */
|
|
115
194
|
variantIncludeInventory?: boolean;
|
|
195
|
+
/**
|
|
196
|
+
* Shelf: include every SKU as a lean variant in isVariantOf.hasVariant[] (via
|
|
197
|
+
* toProductVariant) instead of a single in-stock one. Lets shelf cards render
|
|
198
|
+
* the full size/color grid without pulling the heavy toProduct payload.
|
|
199
|
+
* Default false — preserves the lean single-variant shelf other sites rely on.
|
|
200
|
+
*/
|
|
201
|
+
shelfCompleteVariants?: boolean;
|
|
116
202
|
}
|
|
117
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
|
+
|
|
118
226
|
/** Returns first available sku */
|
|
119
227
|
const findFirstAvailable = (items: Array<LegacySkuVTEX | SkuVTEX>) =>
|
|
120
228
|
items?.find((item) =>
|
|
@@ -388,17 +496,31 @@ export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
|
388
496
|
: toAdditionalProperties(sku);
|
|
389
497
|
const referenceIdAdditionalProperty = toAdditionalPropertyReferenceIds(referenceId);
|
|
390
498
|
const images = nonEmptyArray(sku.images);
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
499
|
+
const rawOffers = (sku.sellers ?? []).map(isLegacyProduct(product) ? toOfferLegacy : toOffer);
|
|
500
|
+
const offers = applyPriceSpecifications(rawOffers, options);
|
|
501
|
+
|
|
502
|
+
// `variantKeepLadder` must NOT be inherited: while it leaked through, every
|
|
503
|
+
// lean variant emitted a ladder instead of an empty one — measured on a real
|
|
504
|
+
// listing, `isVariantOf` GREW from 38.6 KB to 49.6 KB, so the option made
|
|
505
|
+
// the payload bigger. Only the entry `displayedVariantId` picks turns it on,
|
|
506
|
+
// explicitly, below.
|
|
507
|
+
const variantOptions = { ...options, imagesByKey, variantKeepLadder: false };
|
|
395
508
|
const isVariantOf =
|
|
396
509
|
level < 1
|
|
397
510
|
? ({
|
|
398
511
|
"@type": "ProductGroup",
|
|
399
512
|
productGroupID: productId,
|
|
400
513
|
hasVariant: options.leanVariants
|
|
401
|
-
?
|
|
514
|
+
? ((keepId) =>
|
|
515
|
+
items.map((sku) =>
|
|
516
|
+
toProductVariant(
|
|
517
|
+
product,
|
|
518
|
+
sku,
|
|
519
|
+
keepId !== undefined && sku.itemId === keepId
|
|
520
|
+
? { ...variantOptions, variantKeepLadder: true }
|
|
521
|
+
: variantOptions,
|
|
522
|
+
),
|
|
523
|
+
))(options.displayedVariantId?.(items))
|
|
402
524
|
: items.map((sku) => toProduct(product, sku, 1, variantOptions)),
|
|
403
525
|
url: getProductGroupURL(baseUrl, product).href,
|
|
404
526
|
name: product.productName,
|
|
@@ -410,7 +532,8 @@ export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
|
410
532
|
} satisfies ProductGroup)
|
|
411
533
|
: undefined;
|
|
412
534
|
|
|
413
|
-
const
|
|
535
|
+
const cappedImages = capImages(images, options.maxImages, options.keepImageNames);
|
|
536
|
+
const finalImages = cappedImages?.map(({ imageUrl, imageText, imageLabel }) => {
|
|
414
537
|
const url = imagesByKey.get(getImageKey(imageUrl)) ?? imageUrl;
|
|
415
538
|
const alternateName = imageText || imageLabel || "";
|
|
416
539
|
const name = imageLabel || "";
|
|
@@ -502,61 +625,48 @@ export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
|
502
625
|
};
|
|
503
626
|
};
|
|
504
627
|
|
|
505
|
-
/**
|
|
506
|
-
* Determines if an installment has no interest by checking if
|
|
507
|
-
* billingDuration * billingIncrement ≈ total price (within 1 cent tolerance).
|
|
508
|
-
*/
|
|
509
|
-
const isNoInterest = (spec: UnitPriceSpecification): boolean => {
|
|
510
|
-
if (spec.billingDuration == null || spec.billingIncrement == null || spec.price == null) {
|
|
511
|
-
return false;
|
|
512
|
-
}
|
|
513
|
-
return Math.abs(spec.billingDuration * spec.billingIncrement - spec.price) < 0.01;
|
|
514
|
-
};
|
|
515
|
-
|
|
516
628
|
/**
|
|
517
629
|
* Build a lean offer for shelf display. Keeps only:
|
|
518
630
|
* - ListPrice, SalePrice, SRP price types
|
|
519
631
|
* - PIX installment (name?.toUpperCase() === "PIX")
|
|
520
|
-
* - Best
|
|
632
|
+
* - Best installment, chosen with the SAME `bestInstallment` heuristic `useOffer`
|
|
633
|
+
* applies on PLP/PDP (lowest total price, tie-broken by highest billingDuration).
|
|
634
|
+
* Reusing it means shelf cards and detail pages always show the same installment.
|
|
521
635
|
* Drops: inventoryLevel, giftSkuIds, priceValidUntil
|
|
522
636
|
*/
|
|
523
|
-
const buildOfferShelf = (offer: Offer): Offer => {
|
|
637
|
+
export const buildOfferShelf = (offer: Offer): Offer => {
|
|
524
638
|
const leanSpecs: UnitPriceSpecification[] = [];
|
|
525
|
-
|
|
526
|
-
let bestNoInterest: UnitPriceSpecification | null = null;
|
|
639
|
+
const installmentSpecs: UnitPriceSpecification[] = [];
|
|
527
640
|
|
|
528
641
|
for (const spec of offer.priceSpecification ?? []) {
|
|
529
|
-
// Keep base price types
|
|
642
|
+
// Keep base (non-installment) price types
|
|
530
643
|
if (
|
|
531
|
-
spec.priceType === SCHEMA_LIST_PRICE ||
|
|
532
|
-
|
|
533
|
-
|
|
644
|
+
(spec.priceType === SCHEMA_LIST_PRICE ||
|
|
645
|
+
spec.priceType === SCHEMA_SALE_PRICE ||
|
|
646
|
+
spec.priceType === SCHEMA_SRP) &&
|
|
647
|
+
spec.priceComponentType !== SCHEMA_INSTALLMENT
|
|
534
648
|
) {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
649
|
+
leanSpecs.push(spec);
|
|
650
|
+
continue;
|
|
539
651
|
}
|
|
540
652
|
|
|
541
|
-
// Keep PIX installment
|
|
653
|
+
// Keep PIX installment (also feeds the bestInstallment reducer below via leanSpecs)
|
|
542
654
|
if (spec.priceComponentType === SCHEMA_INSTALLMENT && spec.name?.toUpperCase() === "PIX") {
|
|
543
655
|
leanSpecs.push(spec);
|
|
544
656
|
continue;
|
|
545
657
|
}
|
|
546
658
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
spec.priceComponentType === SCHEMA_INSTALLMENT &&
|
|
550
|
-
isNoInterest(spec) &&
|
|
551
|
-
(bestNoInterest == null ||
|
|
552
|
-
(spec.billingDuration ?? 0) > (bestNoInterest.billingDuration ?? 0))
|
|
553
|
-
) {
|
|
554
|
-
bestNoInterest = spec;
|
|
659
|
+
if (spec.priceComponentType === SCHEMA_INSTALLMENT) {
|
|
660
|
+
installmentSpecs.push(spec);
|
|
555
661
|
}
|
|
556
662
|
}
|
|
557
663
|
|
|
558
|
-
|
|
559
|
-
|
|
664
|
+
// Keep the single installment useOffer would pick from the full list. PIX is kept
|
|
665
|
+
// separately above, so useOffer on the lean offer still reduces over {PIX, best}
|
|
666
|
+
// and matches the detail page's reduce over the full set.
|
|
667
|
+
const best = installmentSpecs.reduce(bestInstallment, null as UnitPriceSpecification | null);
|
|
668
|
+
if (best) {
|
|
669
|
+
leanSpecs.push(best);
|
|
560
670
|
}
|
|
561
671
|
|
|
562
672
|
return {
|
|
@@ -572,6 +682,22 @@ const buildOfferShelf = (offer: Offer): Offer => {
|
|
|
572
682
|
};
|
|
573
683
|
};
|
|
574
684
|
|
|
685
|
+
/**
|
|
686
|
+
* Apply `options.priceSpecifications` to every offer, if the caller supplied it.
|
|
687
|
+
* Identity (same array reference) when absent, so the default output is
|
|
688
|
+
* byte-for-byte unchanged.
|
|
689
|
+
*/
|
|
690
|
+
const applyPriceSpecifications = (offers: Offer[], options: ProductOptions): Offer[] =>
|
|
691
|
+
options.priceSpecifications
|
|
692
|
+
? offers.map((offer) => ({
|
|
693
|
+
...offer,
|
|
694
|
+
priceSpecification: options.priceSpecifications!(offer.priceSpecification ?? []),
|
|
695
|
+
}))
|
|
696
|
+
: offers;
|
|
697
|
+
|
|
698
|
+
/** Default positional image cap for the shelf transform (front + back). */
|
|
699
|
+
const SHELF_MAX_IMAGES = 2;
|
|
700
|
+
|
|
575
701
|
/** Property names commonly used by ProductCard/Shelf components */
|
|
576
702
|
const SHELF_PROPERTY_NAMES = new Set([
|
|
577
703
|
"category",
|
|
@@ -587,11 +713,12 @@ const SHELF_PROPERTY_NAMES = new Set([
|
|
|
587
713
|
* Lean product transform for shelf/card display. Same signature as toProduct().
|
|
588
714
|
*
|
|
589
715
|
* Differences from toProduct():
|
|
590
|
-
* - 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
|
|
591
718
|
* - Offers: best seller only (in-stock first, then cheapest), stripped installments (keeps ListPrice, SalePrice, SRP, PIX, best no-interest)
|
|
592
719
|
* - isVariantOf: single in-stock variant at level 0
|
|
593
720
|
* - additionalProperty: filtered to known-used property names
|
|
594
|
-
* - Drops: description, video, isAccessoryOrSparePartFor, alternateName, gtin, releaseDate
|
|
721
|
+
* - Drops: description, video, isAccessoryOrSparePartFor, alternateName, gtin, releaseDate
|
|
595
722
|
*/
|
|
596
723
|
export const toProductShelf = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
597
724
|
product: P,
|
|
@@ -600,12 +727,14 @@ export const toProductShelf = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
|
600
727
|
options: ProductOptions,
|
|
601
728
|
): Product => {
|
|
602
729
|
const { baseUrl, priceCurrency } = options;
|
|
603
|
-
const { productId, items } = product;
|
|
730
|
+
const { productId, items, productReference } = product;
|
|
604
731
|
const { name, itemId: skuId } = sku;
|
|
605
732
|
|
|
606
|
-
// Images: cap at 2
|
|
733
|
+
// Images: cap at 2, keeping any label the caller selects by name
|
|
607
734
|
const rawImages = nonEmptyArray(sku.images);
|
|
608
|
-
const
|
|
735
|
+
const cappedImages =
|
|
736
|
+
capImages(rawImages, options.maxImages ?? SHELF_MAX_IMAGES, options.keepImageNames) ?? [];
|
|
737
|
+
const mappedImages = cappedImages.map(({ imageUrl, imageText, imageLabel }) => ({
|
|
609
738
|
"@type": "ImageObject" as const,
|
|
610
739
|
alternateName: imageText || imageLabel || "",
|
|
611
740
|
url: imageUrl,
|
|
@@ -649,17 +778,38 @@ export const toProductShelf = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
|
649
778
|
level < 1
|
|
650
779
|
? (() => {
|
|
651
780
|
const inStockSku = findFirstAvailable(items) ?? items[0];
|
|
652
|
-
|
|
781
|
+
// Opt-in: every SKU as a lean variant (size/color grid on shelf cards).
|
|
782
|
+
// Default: a single in-stock variant (lean shelf payload).
|
|
783
|
+
const keepId = options.displayedVariantId?.(items);
|
|
784
|
+
const hasVariant = options.shelfCompleteVariants
|
|
785
|
+
? items.map((variantSku) =>
|
|
786
|
+
toProductVariant(
|
|
787
|
+
product,
|
|
788
|
+
variantSku,
|
|
789
|
+
keepId !== undefined && variantSku.itemId === keepId
|
|
790
|
+
? { ...options, variantKeepLadder: true }
|
|
791
|
+
: options,
|
|
792
|
+
),
|
|
793
|
+
)
|
|
794
|
+
: inStockSku
|
|
795
|
+
? [toProductShelf(product, inStockSku, 1, options)]
|
|
796
|
+
: [];
|
|
653
797
|
return {
|
|
654
798
|
"@type": "ProductGroup" as const,
|
|
655
799
|
productGroupID: productId,
|
|
656
|
-
hasVariant
|
|
800
|
+
hasVariant,
|
|
657
801
|
url: getProductGroupURL(baseUrl, product).href,
|
|
658
802
|
name: product.productName,
|
|
659
803
|
// Carry the shelf-safe group specs (incl. "Campanha") so ProductCard
|
|
660
804
|
// flags that read isVariantOf.additionalProperty (e.g. ReleaseFlag ->
|
|
661
805
|
// "Lançamento") still fire on shelf/carousel cards.
|
|
662
806
|
additionalProperty: groupAdditionalProperty,
|
|
807
|
+
// The product reference (VTEX `productReference`). Analytics reads it
|
|
808
|
+
// off `isVariantOf.model` — GA4 `dimension1` on view_item_list /
|
|
809
|
+
// select_item, and the equivalent on productImpression /
|
|
810
|
+
// productClick. Dropping it silently blanked that dimension on every
|
|
811
|
+
// shelf, PLP and search result. One short string per product.
|
|
812
|
+
model: productReference,
|
|
663
813
|
} satisfies ProductGroup;
|
|
664
814
|
})()
|
|
665
815
|
: undefined;
|
|
@@ -726,19 +876,38 @@ export const toProductVariant = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
|
726
876
|
const includeImage = options.variantIncludeImage !== false;
|
|
727
877
|
const includeInventory = options.variantIncludeInventory !== false;
|
|
728
878
|
|
|
729
|
-
// additionalProperty:
|
|
879
|
+
// additionalProperty: variant-differentiating specs, plus the SKU reference
|
|
880
|
+
// ids. Analytics reads the SKU's `RefId` off this list — GA4 `dimension2` on
|
|
881
|
+
// view_item_list / select_item, and the equivalent on productImpression /
|
|
882
|
+
// productClick. It lives in `referenceId`, not in `variations`, so the
|
|
883
|
+
// `variantProps` filter dropped it and blanked that dimension on every shelf
|
|
884
|
+
// card rendered with `shelfCompleteVariants`. One short string per SKU.
|
|
730
885
|
const specificationsAdditionalProperty = isLegacySku(sku)
|
|
731
886
|
? toAdditionalPropertiesLegacy(sku)
|
|
732
887
|
: toAdditionalProperties(sku);
|
|
733
|
-
const additionalProperty =
|
|
734
|
-
variantProps.has(prop.name ?? ""),
|
|
735
|
-
|
|
888
|
+
const additionalProperty = [
|
|
889
|
+
...specificationsAdditionalProperty.filter((prop) => variantProps.has(prop.name ?? "")),
|
|
890
|
+
...(toAdditionalPropertyReferenceIds(sku.referenceId ?? []) ?? []),
|
|
891
|
+
];
|
|
736
892
|
|
|
737
893
|
// Offers: best seller, lean (availability + seller; optional inventoryLevel)
|
|
738
894
|
const offerConverter = isLegacyProduct(product) ? toOfferLegacy : toOffer;
|
|
739
895
|
const allOffers = (sku.sellers ?? []).map(offerConverter).sort(bestOfferFirst);
|
|
740
896
|
const bestOffer = allOffers[0];
|
|
741
|
-
|
|
897
|
+
// `variantKeepLadder` marks the ONE variant the card renders (see
|
|
898
|
+
// displayedVariantId): it keeps the LEAN shape but needs a real ladder,
|
|
899
|
+
// through the same `priceSpecifications` hook the root offer uses.
|
|
900
|
+
const leanOffers = bestOffer
|
|
901
|
+
? [
|
|
902
|
+
options.variantKeepLadder
|
|
903
|
+
? {
|
|
904
|
+
...buildOfferVariant(bestOffer, includeInventory),
|
|
905
|
+
priceSpecification: applyPriceSpecifications([bestOffer], options)[0]
|
|
906
|
+
.priceSpecification,
|
|
907
|
+
}
|
|
908
|
+
: buildOfferVariant(bestOffer, includeInventory),
|
|
909
|
+
]
|
|
910
|
+
: [];
|
|
742
911
|
|
|
743
912
|
// image[0] only — selectors render a single thumbnail. Reuse the same
|
|
744
913
|
// imagesByKey lookup toProduct uses so URLs stay consistent across variants.
|
package/src/utils/vtexId.ts
CHANGED
|
@@ -20,9 +20,17 @@ const VTEX_AUTH_COOKIE = "VtexIdclientAutCookie";
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Extract the VtexIdclientAutCookie value from a cookie string.
|
|
23
|
+
*
|
|
24
|
+
* Matches BOTH the base cookie `VtexIdclientAutCookie=` and the account-suffixed
|
|
25
|
+
* variant `VtexIdclientAutCookie_{account}=`. VTEX frequently sets ONLY the
|
|
26
|
+
* suffixed variant on the storefront domain — matching just the base name makes
|
|
27
|
+
* genuinely logged-in users look anonymous, which defeats any logged-in cache
|
|
28
|
+
* bypass built on top of this and can leak personalized/cached responses.
|
|
23
29
|
*/
|
|
24
30
|
export function extractVtexAuthCookie(cookieHeader: string): string | null {
|
|
25
|
-
const match = cookieHeader.match(
|
|
31
|
+
const match = cookieHeader.match(
|
|
32
|
+
new RegExp(`(?:^|;\\s*)${VTEX_AUTH_COOKIE}(?:_[^=;\\s]+)?=([^;]+)`),
|
|
33
|
+
);
|
|
26
34
|
return match?.[1] ?? null;
|
|
27
35
|
}
|
|
28
36
|
|
|
@@ -48,7 +56,12 @@ function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
|
48
56
|
export function parseVtexAuthToken(token: string): VtexAuthInfo {
|
|
49
57
|
const payload = decodeJwtPayload(token);
|
|
50
58
|
if (!payload) {
|
|
51
|
-
|
|
59
|
+
// Opaque (non-JWT) token. VTEX auth cookies are frequently opaque strings
|
|
60
|
+
// rather than JWTs — we cannot read `exp`, but the mere presence of the
|
|
61
|
+
// cookie means the user IS authenticated. Treating it as logged-out (the
|
|
62
|
+
// previous behavior) makes real sessions look anonymous and defeats any
|
|
63
|
+
// logged-in cache bypass. Fail towards "logged in" (i.e. do-not-cache).
|
|
64
|
+
return { isLoggedIn: true, isExpired: false };
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
const exp = typeof payload.exp === "number" ? payload.exp : undefined;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
/**
|
|
3
|
+
* The VTEX upstream client (/next/upstream-clients): typed calls over the
|
|
4
|
+
* framework's instrumented fetch, labeled provider "vtex" with a named
|
|
5
|
+
* operation, retries and a circuit breaker on by default, per-shopper inputs
|
|
6
|
+
* as arguments, and errors that carry no body, URL, token or cookie.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
const instrumented = vi.hoisted(() => ({
|
|
11
|
+
options: [] as Record<string, unknown>[],
|
|
12
|
+
operations: [] as (string | undefined)[],
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("@decocms/blocks/fetch", async (importActual) => {
|
|
16
|
+
const actual = await importActual<typeof import("@decocms/blocks/fetch")>();
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
createInstrumentedFetch: (options: Parameters<typeof actual.createInstrumentedFetch>[0]) => {
|
|
20
|
+
instrumented.options.push(options as unknown as Record<string, unknown>);
|
|
21
|
+
const request = actual.createInstrumentedFetch(options);
|
|
22
|
+
return (input: string | URL | Request, init?: RequestInit & { operation?: string }) => {
|
|
23
|
+
instrumented.operations.push(init?.operation);
|
|
24
|
+
return request(input, init);
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
import { createVtexClient, VtexError } from ".";
|
|
31
|
+
|
|
32
|
+
type Call = { url: string; init: RequestInit };
|
|
33
|
+
|
|
34
|
+
/** An upstream answering with `statuses` in order, recording each request. */
|
|
35
|
+
function upstream(statuses: number[] = [], headers: Record<string, string | string[]> = {}) {
|
|
36
|
+
const calls: Call[] = [];
|
|
37
|
+
const fetch = vi.fn(async (input: string | URL | Request, init: RequestInit = {}) => {
|
|
38
|
+
calls.push({ url: String(input), init });
|
|
39
|
+
const response = new Response('{"secret-body":"token-123"}', { status: statuses.shift() ?? 200 });
|
|
40
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
41
|
+
for (const v of Array.isArray(value) ? value : [value]) response.headers.append(name, v);
|
|
42
|
+
}
|
|
43
|
+
return response;
|
|
44
|
+
});
|
|
45
|
+
return { calls, fetch: fetch as unknown as typeof globalThis.fetch };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const header = (call: Call | undefined, name: string) => new Headers(call?.init.headers).get(name);
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
instrumented.options.length = 0;
|
|
52
|
+
instrumented.operations.length = 0;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("createVtexClient", () => {
|
|
56
|
+
it("uses the instrumented fetch as provider vtex, with retries and a circuit breaker on", () => {
|
|
57
|
+
createVtexClient({ account: "store" });
|
|
58
|
+
expect(instrumented.options[0]).toMatchObject({
|
|
59
|
+
provider: "vtex",
|
|
60
|
+
retry: { attempts: 2, backoffMs: 150 },
|
|
61
|
+
circuitBreaker: { failures: 5, cooldownMs: 5000 },
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("turns retries and the circuit breaker off when asked", () => {
|
|
66
|
+
createVtexClient({ account: "store", retry: false, circuitBreaker: false });
|
|
67
|
+
expect(instrumented.options[0]?.retry).toBeUndefined();
|
|
68
|
+
expect(instrumented.options[0]?.circuitBreaker).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("searches Intelligent Search with facets, region, locale and sales channel as arguments", async () => {
|
|
72
|
+
const { calls, fetch } = upstream();
|
|
73
|
+
const vtex = createVtexClient({
|
|
74
|
+
account: "store",
|
|
75
|
+
appKey: "key",
|
|
76
|
+
appToken: "token",
|
|
77
|
+
salesChannel: "2",
|
|
78
|
+
locale: "pt-BR",
|
|
79
|
+
fetch,
|
|
80
|
+
});
|
|
81
|
+
await vtex.search.products({
|
|
82
|
+
query: "linen shirt",
|
|
83
|
+
count: 12,
|
|
84
|
+
facets: [{ key: "category-1", value: "shirts" }],
|
|
85
|
+
regionId: "v2.ABC",
|
|
86
|
+
});
|
|
87
|
+
const url = new URL(calls[0]!.url);
|
|
88
|
+
expect(url.origin).toBe("https://store.vtexcommercestable.com.br");
|
|
89
|
+
expect(url.pathname).toBe("/api/io/_v/api/intelligent-search/product_search/category-1/shirts");
|
|
90
|
+
expect(Object.fromEntries(url.searchParams)).toEqual({
|
|
91
|
+
query: "linen shirt",
|
|
92
|
+
count: "12",
|
|
93
|
+
locale: "pt-BR",
|
|
94
|
+
regionId: "v2.ABC",
|
|
95
|
+
sc: "2",
|
|
96
|
+
});
|
|
97
|
+
expect(header(calls[0], "x-vtex-api-appkey")).toBe("key");
|
|
98
|
+
expect(header(calls[0], "x-vtex-api-apptoken")).toBe("token");
|
|
99
|
+
expect(header(calls[0], "cookie")).toBeNull();
|
|
100
|
+
expect(instrumented.operations).toEqual(["intelligent-search.product_search"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("names every operation from the VTEX API, never a URL", async () => {
|
|
104
|
+
const { fetch } = upstream();
|
|
105
|
+
const vtex = createVtexClient({ account: "store", fetch });
|
|
106
|
+
await vtex.catalog.pageType("/shirts/linen");
|
|
107
|
+
await vtex.catalog.products({ fq: ["productId:1", "productId:2"], from: 0, to: 9 });
|
|
108
|
+
await vtex.checkout.simulation({ items: [{ id: "1", quantity: 1, seller: "1" }] });
|
|
109
|
+
await vtex.checkout.regions({ postalCode: "01000-000", country: "BRA" });
|
|
110
|
+
await vtex.sessions.get();
|
|
111
|
+
expect(instrumented.operations).toEqual([
|
|
112
|
+
"catalog.pagetype",
|
|
113
|
+
"catalog.products.search",
|
|
114
|
+
"checkout.simulation",
|
|
115
|
+
"checkout.regions",
|
|
116
|
+
"sessions.get",
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("retries an idempotent call and fails fast once the breaker opens", async () => {
|
|
121
|
+
const { calls, fetch } = upstream([503, 200]);
|
|
122
|
+
const vtex = createVtexClient({ account: "store", fetch, retry: { attempts: 1, backoffMs: 0 } });
|
|
123
|
+
await expect(vtex.catalog.categoryTree()).resolves.toBeDefined();
|
|
124
|
+
expect(calls).toHaveLength(2);
|
|
125
|
+
|
|
126
|
+
const down = upstream([500, 500, 500]);
|
|
127
|
+
const failing = createVtexClient({
|
|
128
|
+
account: "store",
|
|
129
|
+
fetch: down.fetch,
|
|
130
|
+
retry: false,
|
|
131
|
+
circuitBreaker: { failures: 2, cooldownMs: 60_000 },
|
|
132
|
+
});
|
|
133
|
+
await expect(failing.catalog.categoryTree()).rejects.toBeInstanceOf(VtexError);
|
|
134
|
+
await expect(failing.catalog.categoryTree()).rejects.toBeInstanceOf(VtexError);
|
|
135
|
+
await expect(failing.catalog.categoryTree()).rejects.toThrow(/circuit open/);
|
|
136
|
+
expect(down.calls).toHaveLength(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("never retries a cart write", async () => {
|
|
140
|
+
const { calls, fetch } = upstream([503, 200]);
|
|
141
|
+
const vtex = createVtexClient({ account: "store", fetch, retry: { attempts: 3, backoffMs: 0 } });
|
|
142
|
+
await expect(vtex.checkout.addItems("of1", [{ id: 1, quantity: 1, seller: "1" }])).rejects.toThrow(
|
|
143
|
+
VtexError,
|
|
144
|
+
);
|
|
145
|
+
expect(calls).toHaveLength(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("forwards the shopper's cookie, sanitized, and hands back VTEX's Set-Cookie values", async () => {
|
|
149
|
+
const { calls, fetch } = upstream([], {
|
|
150
|
+
"set-cookie": ["checkout.vtex.com=__ofid=of1; Domain=store.vtexcommercestable.com.br; Path=/"],
|
|
151
|
+
});
|
|
152
|
+
const vtex = createVtexClient({ account: "store", appKey: "key", appToken: "token", fetch });
|
|
153
|
+
const result = await vtex.checkout.orderForm({
|
|
154
|
+
cookie: "checkout.vtex.com=__ofid=of1; tag=cateçoria",
|
|
155
|
+
});
|
|
156
|
+
expect(header(calls[0], "cookie")).toBe("checkout.vtex.com=__ofid=of1");
|
|
157
|
+
expect(calls[0]?.init.method).toBe("POST");
|
|
158
|
+
// Every orderForm section, and no app credentials next to a shopper's cookie.
|
|
159
|
+
expect(calls[0]?.init.body).toBe("{}");
|
|
160
|
+
expect(header(calls[0], "x-vtex-api-appkey")).toBeNull();
|
|
161
|
+
expect(header(calls[0], "x-vtex-api-apptoken")).toBeNull();
|
|
162
|
+
expect(result.setCookies).toEqual([
|
|
163
|
+
"checkout.vtex.com=__ofid=of1; Domain=store.vtexcommercestable.com.br; Path=/",
|
|
164
|
+
]);
|
|
165
|
+
expect(result.data).toEqual({ "secret-body": "token-123" });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("throws errors that carry the operation and status, never the body, URL or credentials", async () => {
|
|
169
|
+
const { fetch } = upstream([400]);
|
|
170
|
+
const vtex = createVtexClient({ account: "store", appKey: "key", appToken: "token-123", fetch });
|
|
171
|
+
const error = await vtex.search.products({ query: "q" }).catch((e: unknown) => e);
|
|
172
|
+
expect(error).toBeInstanceOf(VtexError);
|
|
173
|
+
expect(error).toMatchObject({ operation: "intelligent-search.product_search", status: 400 });
|
|
174
|
+
expect((error as Error).message).toBe("vtex intelligent-search.product_search failed with HTTP 400");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("narrows the orderForm sections only when asked", async () => {
|
|
178
|
+
const { calls, fetch } = upstream();
|
|
179
|
+
const vtex = createVtexClient({ account: "store", fetch });
|
|
180
|
+
await vtex.checkout.orderForm({ sections: ["items", "totalizers"] });
|
|
181
|
+
expect(calls[0]?.init.body).toBe('{"expectedOrderFormSections":["items","totalizers"]}');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("keeps caller paths inside their endpoint", async () => {
|
|
185
|
+
const { calls, fetch } = upstream();
|
|
186
|
+
const vtex = createVtexClient({ account: "store", appKey: "key", appToken: "token", fetch });
|
|
187
|
+
for (const path of [
|
|
188
|
+
"../../api/dataentities/CL/search",
|
|
189
|
+
"/shirts/%2e%2e/%2E%2E/api/dataentities/CL/search",
|
|
190
|
+
"shirts/./linen",
|
|
191
|
+
]) {
|
|
192
|
+
await expect(vtex.catalog.pageType(path)).rejects.toThrow(/invalid path segment/);
|
|
193
|
+
}
|
|
194
|
+
await expect(vtex.catalog.products({ term: "a/../../../api/x" })).rejects.toThrow(
|
|
195
|
+
/invalid path segment/,
|
|
196
|
+
);
|
|
197
|
+
expect(calls).toHaveLength(0);
|
|
198
|
+
|
|
199
|
+
await vtex.catalog.pageType("/shirts/linen?_where=x#y");
|
|
200
|
+
const url = new URL(calls[0]!.url);
|
|
201
|
+
expect(url.pathname).toBe("/api/catalog_system/pub/portal/pagetype/shirts/linen%3F_where%3Dx%23y");
|
|
202
|
+
expect(url.search).toBe("");
|
|
203
|
+
});
|
|
204
|
+
});
|