@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.
Files changed (34) hide show
  1. package/package.json +5 -5
  2. package/src/__conformance__/upstream.test.ts +158 -0
  3. package/src/__tests__/client-set-cookie-forward.test.ts +4 -4
  4. package/src/client.ts +61 -4
  5. package/src/index.ts +17 -2
  6. package/src/loaders/__tests__/legacyProductList.test.ts +157 -0
  7. package/src/loaders/autocomplete.ts +2 -4
  8. package/src/loaders/intelligentSearch/productDetailsPage.ts +2 -4
  9. package/src/loaders/intelligentSearch/productList.ts +21 -5
  10. package/src/loaders/intelligentSearch/productListingPage.ts +2 -3
  11. package/src/loaders/intelligentSearch/suggestions.ts +2 -4
  12. package/src/loaders/legacy/relatedProductsLoader.ts +2 -4
  13. package/src/loaders/legacy.ts +77 -2
  14. package/src/loaders/productListFull.ts +2 -4
  15. package/src/loaders/workflow/products.ts +2 -4
  16. package/src/middleware.cacheHeaders.test.ts +44 -0
  17. package/src/mod.ts +18 -2
  18. package/src/utils/__tests__/cookieSanitizer.test.ts +1 -1
  19. package/src/utils/__tests__/storefrontBaseUrl.test.ts +61 -0
  20. package/src/utils/__tests__/transform.test.ts +349 -7
  21. package/src/utils/buildOfferShelf.test.ts +59 -0
  22. package/src/utils/enrichment.ts +3 -7
  23. package/src/utils/fetch.ts +5 -1
  24. package/src/utils/fetchCache.ts +24 -168
  25. package/src/utils/instrumentedFetch.ts +2 -1
  26. package/src/utils/minicart.ts +1 -1
  27. package/src/utils/operationRouter.ts +3 -9
  28. package/src/utils/proxy.ts +64 -4
  29. package/src/utils/similars.ts +2 -4
  30. package/src/utils/sitemap.ts +3 -2
  31. package/src/utils/transform.ts +222 -53
  32. package/src/utils/vtexId.ts +15 -2
  33. package/src/vtexClient.test.ts +204 -0
  34. package/src/vtexClient.ts +433 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps-vtex",
3
- "version": "8.0.0",
3
+ "version": "8.1.0-next.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": "8.0.0",
55
- "@decocms/apps-commerce": "8.0.0",
56
- "@decocms/apps-website": "8.0.0",
57
- "@decocms/tanstack": "8.0.0"
54
+ "@decocms/blocks": "8.1.0-next.0",
55
+ "@decocms/apps-commerce": "8.1.0-next.0",
56
+ "@decocms/apps-website": "8.1.0-next.0",
57
+ "@decocms/tanstack": "8.1.0-next.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@tanstack/react-query": ">=5.0.0",
@@ -0,0 +1,158 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * Conformance: upstream-clients.mdx (Calling APIs) against the next-major
4
+ * client packages. Only the v8 client modules are checked; the v7 app
5
+ * surfaces that still ship beside them are out of scope until v7 is removed.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { createAlgoliaClient } from "@decocms/apps-algolia";
11
+ import { createVtexClient } from "@decocms/apps-vtex";
12
+ import { describe, expect, it, vi } from "vitest";
13
+
14
+ const PACKAGES = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
15
+
16
+ /** The next-major client module of each platform package. */
17
+ const V8_CLIENTS: Record<string, string[]> = {
18
+ "apps-vtex": ["src/vtexClient.ts"],
19
+ "apps-shopify": ["src/v8/client.ts", "src/v8/graphqlOperationName.ts"],
20
+ "apps-wake": ["src/wakeClient.ts"],
21
+ "apps-magento": ["src/magentoClient.ts"],
22
+ "apps-algolia": ["src/index.ts"],
23
+ "apps-resend": ["src/emails.ts"],
24
+ "apps-sfmc-personalization": ["src/index.ts"],
25
+ };
26
+
27
+ /** The source without comments, so doc examples (`process.env.X!` in a JSDoc) don't count. */
28
+ function code(pkg: string, file: string): string {
29
+ return fs
30
+ .readFileSync(path.join(PACKAGES, pkg, file), "utf8")
31
+ .replace(/\/\*[\s\S]*?\*\//g, "")
32
+ .replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
33
+ }
34
+
35
+ describe("what a client is (upstream-clients.mdx)", () => {
36
+ it("up-01: VTEX, Shopify, Wake, Magento, Algolia, Resend (and others) ship a client built on createInstrumentedFetch", () => {
37
+ for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
38
+ expect(fs.existsSync(path.join(PACKAGES, pkg, "package.json"))).toBe(true);
39
+ expect([pkg, code(pkg, files[0]!)]).toEqual([
40
+ pkg,
41
+ expect.stringMatching(/import \{[^}]*createInstrumentedFetch[^}]*\} from "@decocms\/blocks\/fetch"/),
42
+ ]);
43
+ }
44
+ });
45
+
46
+ it("up-02: clients take settings as arguments: no environment reads inside a client", () => {
47
+ for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
48
+ for (const file of files) {
49
+ expect([pkg, file, /process\.env|import\.meta\.env|Deno\.env/.test(code(pkg, file))]).toEqual(
50
+ [pkg, file, false],
51
+ );
52
+ }
53
+ }
54
+ });
55
+
56
+ it("up-03: no React hooks, client components or shared-commerce converters in a client", () => {
57
+ for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
58
+ for (const file of files) {
59
+ const text = code(pkg, file);
60
+ const found = [
61
+ /["']use client["']/,
62
+ /from ["']react["']/,
63
+ /\buse(Cart|User|Wishlist)\b/,
64
+ /@decocms\/apps-commerce/,
65
+ /from ["']\.\/(loaders|actions|hooks)\//,
66
+ ].filter((pattern) => pattern.test(text));
67
+ expect([pkg, file, found]).toEqual([pkg, file, []]);
68
+ }
69
+ }
70
+ });
71
+
72
+ it("up-05: the Salesforce client is @decocms/apps-sfmc-personalization; @decocms/apps-salesforce is gone", () => {
73
+ const pkg = JSON.parse(
74
+ fs.readFileSync(path.join(PACKAGES, "apps-sfmc-personalization/package.json"), "utf8"),
75
+ );
76
+ expect(pkg.name).toBe("@decocms/apps-sfmc-personalization");
77
+ const names = fs
78
+ .readdirSync(PACKAGES)
79
+ .filter((dir) => fs.existsSync(path.join(PACKAGES, dir, "package.json")))
80
+ .map((dir) => JSON.parse(fs.readFileSync(path.join(PACKAGES, dir, "package.json"), "utf8")).name);
81
+ expect(names).not.toContain("@decocms/apps-salesforce");
82
+ });
83
+
84
+ it("up-07: clients don't cache upstream responses", () => {
85
+ for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
86
+ for (const file of files) {
87
+ expect([
88
+ pkg,
89
+ file,
90
+ /createFetchCache|fetchWithCache|fetchCache|caches\.default|caches\.open|cachedLoader/.test(
91
+ code(pkg, file),
92
+ ),
93
+ ]).toEqual([pkg, file, false]);
94
+ }
95
+ }
96
+ });
97
+ });
98
+
99
+ describe("call a client (upstream-clients.mdx)", () => {
100
+ it("up-06: createVtexClient({ account, appKey, appToken }) and vtex.search.products({ query, count })", async () => {
101
+ const fetch = vi.fn(
102
+ async (_input: string | URL | Request, _init?: RequestInit) =>
103
+ new Response(JSON.stringify({ products: [] }), { status: 200 }),
104
+ );
105
+ const vtex = createVtexClient({
106
+ account: "mystore",
107
+ appKey: "key",
108
+ appToken: "token",
109
+ fetch: fetch as unknown as typeof globalThis.fetch,
110
+ });
111
+ const products = await vtex.search.products({ query: "linen shirt", count: 12 });
112
+ expect(products).toEqual({ products: [] });
113
+ const url = new URL(String(fetch.mock.calls[0]![0]));
114
+ expect(url.host).toBe("mystore.vtexcommercestable.com.br");
115
+ expect(url.pathname).toContain("product_search");
116
+ expect(url.searchParams.get("query")).toBe("linen shirt");
117
+ expect(url.searchParams.get("count")).toBe("12");
118
+ });
119
+ });
120
+
121
+ describe("retries and failures (upstream-clients.mdx)", () => {
122
+ it("up-08: the VTEX client retries by default", async () => {
123
+ const statuses = [503, 200];
124
+ const fetch = vi.fn(
125
+ async () => new Response("{}", { status: statuses.shift() ?? 200 }),
126
+ );
127
+ const vtex = createVtexClient({
128
+ account: "mystore",
129
+ fetch: fetch as unknown as typeof globalThis.fetch,
130
+ });
131
+ await vtex.search.products({ query: "shirt" });
132
+ expect(fetch).toHaveBeenCalledTimes(2);
133
+ });
134
+
135
+ it("up-08: the VTEX client's circuit breaker fails fast after repeated failures", async () => {
136
+ const fetch = vi.fn(async () => new Response("{}", { status: 503 }));
137
+ const vtex = createVtexClient({
138
+ account: "mystore",
139
+ fetch: fetch as unknown as typeof globalThis.fetch,
140
+ });
141
+ for (let i = 0; i < 5; i++) await expect(vtex.search.products({ query: "shirt" })).rejects.toThrow();
142
+ const before = fetch.mock.calls.length;
143
+ await expect(vtex.search.products({ query: "shirt" })).rejects.toThrow();
144
+ expect(fetch.mock.calls.length).toBe(before);
145
+ }, 20_000);
146
+
147
+ it("up-08: other clients leave retries off (one attempt on a 503)", async () => {
148
+ const fetch = vi.fn(async () => new Response("{}", { status: 503 }));
149
+ const algolia = createAlgoliaClient(
150
+ { applicationId: "APP123", apiKey: "k" },
151
+ { fetch: fetch as unknown as typeof globalThis.fetch },
152
+ );
153
+ await expect(algolia.search([{ indexName: "products", query: "shirt" }] as never)).rejects.toThrow(
154
+ "algolia search failed with HTTP 503",
155
+ );
156
+ expect(fetch).toHaveBeenCalledTimes(1);
157
+ });
158
+ });
@@ -129,11 +129,11 @@ describe("vtexFetchWithCookies — inbound Set-Cookie capture", () => {
129
129
  await vtexFetchWithCookies("/api/checkout/pub/orderForm");
130
130
  return responseHeaders.getSetCookie();
131
131
  },
132
- "https://www.casaevideo.com.br/api/checkout/pub/orderForm",
132
+ "https://www.acme.com.br/api/checkout/pub/orderForm",
133
133
  );
134
134
 
135
135
  // domain-scoped to the storefront host, NOT the original VTEX domain
136
- expect(captured[0]).toMatch(/Domain=www\.casaevideo\.com\.br/);
136
+ expect(captured[0]).toMatch(/Domain=www\.acme\.com\.br/);
137
137
  expect(captured[0]).not.toMatch(/vtexcommercestable/i);
138
138
  expect(captured[0]).toContain("checkout.vtex.com=__ofid=abc");
139
139
  });
@@ -155,12 +155,12 @@ describe("vtexFetchWithCookies — inbound Set-Cookie capture", () => {
155
155
  await vtexFetchWithCookies("/api/checkout/pub/orderForm");
156
156
  return responseHeaders.getSetCookie();
157
157
  },
158
- "https://www.casaevideo.com.br/api/checkout/pub/orderForm",
158
+ "https://www.acme.com.br/api/checkout/pub/orderForm",
159
159
  );
160
160
 
161
161
  // the value's `domain=keep` is untouched; the attribute is rewritten
162
162
  expect(captured[0]).toContain("__ofid=domain=keep");
163
- expect(captured[0]).toMatch(/;\s*Domain=www\.casaevideo\.com\.br/);
163
+ expect(captured[0]).toMatch(/;\s*Domain=www\.acme\.com\.br/);
164
164
  expect(captured[0]).not.toMatch(/vtexcommercestable/i);
165
165
  });
166
166
 
package/src/client.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Uses VTEX's public REST APIs (Intelligent Search + Catalog + Checkout).
4
4
  */
5
5
 
6
+ import { type FetchFn, withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";
6
7
  import type {
7
8
  InstrumentedFetch,
8
9
  InstrumentedFetchInit,
@@ -47,7 +48,7 @@ function getRequestHost(): string | null {
47
48
  * host AND so it lands at the SAME cookie scope as the checkout proxy.
48
49
  *
49
50
  * VTEX sets `checkout.vtex.com` / `CheckoutOrderFormOwnership` with
50
- * `domain=<vtex-host>` (e.g. `casaevideonewio.vtexcommercestable.com.br`),
51
+ * `domain=<vtex-host>` (e.g. `acme.vtexcommercestable.com.br`),
51
52
  * which the browser would reject on the storefront host. There are two ways
52
53
  * to make it acceptable:
53
54
  *
@@ -147,7 +148,7 @@ export interface VtexConfig {
147
148
  }
148
149
 
149
150
  let _config: VtexConfig | null = null;
150
- let _fetch: typeof fetch | InstrumentedFetch = globalThis.fetch;
151
+ let _fetch: FetchFn | InstrumentedFetch = withFetchTimeout();
151
152
 
152
153
  export function configureVtex(config: VtexConfig) {
153
154
  _config = config;
@@ -169,7 +170,7 @@ export function configureVtex(config: VtexConfig) {
169
170
  * uninstrumented (useful for tests + sites that haven't onboarded
170
171
  * the observability stack yet).
171
172
  */
172
- export function setVtexFetch(fetchFn: typeof fetch | InstrumentedFetch) {
173
+ export function setVtexFetch(fetchFn: FetchFn | InstrumentedFetch) {
173
174
  _fetch = fetchFn;
174
175
  }
175
176
 
@@ -205,6 +206,55 @@ export function vtexHost(environment: string = "vtexcommercestable", config?: Vt
205
206
  return `${c.account}.${environment}.${domain}`;
206
207
  }
207
208
 
209
+ /**
210
+ * Origin every product/SKU/breadcrumb URL in the schema.org payload is built
211
+ * from.
212
+ *
213
+ * Order matters, and the first branch is the whole point: a storefront answers
214
+ * on the host the shopper actually typed, and that is the host its canonical
215
+ * URLs, JSON-LD and sitemap entries have to name. deco-cx/apps got this from
216
+ * the request too (`vtex/loaders/intelligentSearch/productList.ts` passes
217
+ * `baseUrl: url`), and dropping it here is what made a migrated storefront
218
+ * publish structured data pointing somewhere else.
219
+ *
220
+ * `publicUrl` is only a fallback. It is the VTEX *checkout* host
221
+ * (`secure.<brand>.com`), which is a different site from the storefront, and
222
+ * it is stored with whatever shape the admin typed — commonly a full URL
223
+ * (`https://secure.example.com/`). Interpolating that into `https://${...}`
224
+ * produced `https://https://secure.example.com/`, which `new URL()` parses
225
+ * with host `https`, so every product ended up at `https://https/<slug>/p`.
226
+ * Normalising through `URL` removes that whole class of bug.
227
+ *
228
+ * Returns no trailing slash, so `new URL(path, baseUrl)` behaves the same for
229
+ * every branch.
230
+ */
231
+ export function storefrontBaseUrl(config?: VtexConfig): string {
232
+ const c = config ?? getVtexConfig();
233
+
234
+ // 1. The host serving this request -- the storefront's own origin.
235
+ const request = RequestContext.current?.request;
236
+ if (request) {
237
+ try {
238
+ return new URL(request.url).origin;
239
+ } catch {
240
+ // Fall through: a synthetic Request may carry an unparseable url.
241
+ }
242
+ }
243
+
244
+ // 2. Configured public URL, normalised whether or not it carries a scheme.
245
+ if (c.publicUrl) {
246
+ const normalised = /^https?:\/\//i.test(c.publicUrl) ? c.publicUrl : `https://${c.publicUrl}`;
247
+ try {
248
+ return new URL(normalised).origin;
249
+ } catch {
250
+ // Fall through to the account host rather than publish a broken origin.
251
+ }
252
+ }
253
+
254
+ // 3. The account's VTEX host. Always well-formed; never the public brand.
255
+ return `https://${vtexHost("vtexcommercestable", c)}`;
256
+ }
257
+
208
258
  function baseUrl(): string {
209
259
  return `https://${vtexHost()}`;
210
260
  }
@@ -325,7 +375,14 @@ export async function vtexFetchResponse(
325
375
  headers: mergeHeaders(authHeaders(), segmentCookie, init?.headers),
326
376
  });
327
377
  if (!response.ok) {
328
- throw new Error(`VTEX API error: ${response.status} ${response.statusText} - ${url}`);
378
+ // Include the response body: VTEX returns actionable detail here
379
+ // (e.g. MasterData "duplicated entry"), and without it callers can't
380
+ // distinguish error kinds and are forced to reimplement the fetch to
381
+ // read the body themselves. Safe to consume — we throw, not return.
382
+ const body = await response.text().catch(() => "");
383
+ throw new Error(
384
+ `VTEX API error: ${response.status} ${response.statusText} - ${url}${body ? ` - ${body}` : ""}`,
385
+ );
329
386
  }
330
387
  return response;
331
388
  }
package/src/index.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  /**
2
- * VTEX app entry point for @decocms/apps.
3
- * Re-exports client config + initializer + app contract.
2
+ * VTEX app entry point.
3
+ *
4
+ * Next major: `createVtexClient` is the thin, instrumented VTEX client
5
+ * (see ./vtexClient.ts and /next/upstream-clients). Everything else below is
6
+ * the v7 app surface, kept exported for v7 consumers until v7 is dropped.
7
+ *
8
+ * v7: re-exports client config + initializer + app contract.
4
9
  *
5
10
  * For actions/loaders/utils, use sub-path imports:
6
11
  * import { addItemsToCart } from "@decocms/apps/vtex/actions/checkout"
@@ -15,3 +20,13 @@ export * from "./client";
15
20
  export { configure, type VtexState } from "./mod";
16
21
  export { type CreateVtexFetchOptions, createVtexFetch } from "./utils/instrumentedFetch";
17
22
  export { vtexOperationRouter } from "./utils/operationRouter";
23
+ export {
24
+ createVtexClient,
25
+ type VtexCatalogSearchArgs,
26
+ type VtexClient,
27
+ type VtexClientConfig,
28
+ VtexError,
29
+ type VtexRequestOptions,
30
+ type VtexResponse,
31
+ type VtexSearchArgs,
32
+ } from "./vtexClient";
@@ -0,0 +1,157 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // `legacyProductList` is the SHELF loader. It accepted no payload-shaping
4
+ // options, so every shelf paid the full product for every entry — measured on a
5
+ // real home shelf (`count: 28`): 1095 KB, 39.1 KB per product, of which
6
+ // `isVariantOf` 19.1 KB and the 48-rung `offers.priceSpecification` 12.8 KB.
7
+ // `legacyProductListingPage` already took them; this file pins the pass-through,
8
+ // because declaring the options WITHOUT forwarding them is exactly the bug the
9
+ // PLP loader had.
10
+
11
+ vi.mock("../../client", () => ({
12
+ getVtexConfig: () => ({ account: "test", salesChannel: "1" }),
13
+ vtexFetch: vi.fn(),
14
+ vtexFetchResponse: vi.fn(),
15
+ }));
16
+
17
+ import { vtexFetch } from "../../client";
18
+ import { legacyProductList } from "../legacy";
19
+
20
+ const sellers = (price: number) => [
21
+ {
22
+ sellerId: "1",
23
+ sellerName: "Seller One",
24
+ sellerDefault: true,
25
+ commertialOffer: {
26
+ AvailableQuantity: 5,
27
+ Price: price,
28
+ ListPrice: price + 30,
29
+ PriceWithoutDiscount: price + 30,
30
+ spotPrice: price,
31
+ PriceValidUntil: "2025-12-31",
32
+ // A real ladder: every method the store accepts, every installment count.
33
+ Installments: ["Visa", "Master", "Amex", "Boleto"].flatMap((method) =>
34
+ Array.from({ length: 4 }, (_, i) => ({
35
+ Value: price / (i + 1),
36
+ NumberOfInstallments: i + 1,
37
+ Name: `${method} ${i + 1}x`,
38
+ InterestRate: 0,
39
+ TotalValuePlusInterestRate: price,
40
+ PaymentSystemName: method,
41
+ })),
42
+ ),
43
+ GiftSkuIds: [],
44
+ teasers: [],
45
+ },
46
+ },
47
+ ];
48
+
49
+ const sku = (itemId: string, price: number) => ({
50
+ itemId,
51
+ name: `SKU ${itemId}`,
52
+ nameComplete: `SKU ${itemId}`,
53
+ complementName: "",
54
+ ean: "1234567890123",
55
+ referenceId: [{ Key: "RefId", Value: `REF-${itemId}` }],
56
+ images: Array.from({ length: 4 }, (_, i) => ({
57
+ imageId: `${itemId}-${i}`,
58
+ imageUrl: `https://img.com/${itemId}-${i}.jpg`,
59
+ imageText: `img${i}`,
60
+ imageLabel: `label${i}`,
61
+ })),
62
+ sellers: sellers(price),
63
+ Videos: [],
64
+ estimatedDateArrival: null,
65
+ measurementUnit: "un",
66
+ unitMultiplier: 1,
67
+ variations: [],
68
+ attachments: [],
69
+ isKit: false,
70
+ });
71
+
72
+ const legacyProduct = () => ({
73
+ productId: "PROD1",
74
+ productName: "Test Product",
75
+ brand: "TestBrand",
76
+ brandId: 1,
77
+ brandImageUrl: null,
78
+ linkText: "test-product",
79
+ productReference: "REF1",
80
+ categoryId: "1",
81
+ productTitle: "Test Product",
82
+ metaTagDescription: "meta",
83
+ clusterHighlights: {},
84
+ productClusters: {},
85
+ searchableClusters: {},
86
+ categories: ["/Electronics/"],
87
+ categoriesIds: ["/1/"],
88
+ link: "https://test/test-product/p",
89
+ description: "x".repeat(2000),
90
+ items: [sku("SKU1", 90), sku("SKU2", 60), sku("SKU3", 120)],
91
+ allSpecifications: [],
92
+ allSpecificationsGroups: [],
93
+ skuSpecifications: [],
94
+ releaseDate: "2024-01-01",
95
+ });
96
+
97
+ const opts = { query: { count: 1 } as any, baseUrl: "https://example.com" };
98
+ const ladderOf = (p: any) => p?.offers?.offers?.[0]?.priceSpecification ?? [];
99
+
100
+ describe("legacyProductList — payload-shaping options reach toProduct", () => {
101
+ beforeEach(() => {
102
+ (vtexFetch as any).mockReset();
103
+ (vtexFetch as any).mockResolvedValue([legacyProduct()]);
104
+ });
105
+
106
+ it("no options: full product, full ladder, every variant, every image", async () => {
107
+ const [p] = (await legacyProductList(opts)) as any[];
108
+ expect(ladderOf(p).length).toBeGreaterThan(10);
109
+ expect(p.isVariantOf.hasVariant).toHaveLength(3);
110
+ expect(ladderOf(p.isVariantOf.hasVariant[0]).length).toBeGreaterThan(10);
111
+ expect(p.image).toHaveLength(4);
112
+ expect(p.description).toBeTruthy();
113
+ });
114
+
115
+ it("leanVariants empties the ladder on every variant", async () => {
116
+ const [p] = (await legacyProductList({ ...opts, leanVariants: true })) as any[];
117
+ for (const v of p.isVariantOf.hasVariant) expect(ladderOf(v)).toEqual([]);
118
+ });
119
+
120
+ it("displayedVariantId keeps the ladder on the one variant the card renders", async () => {
121
+ const [p] = (await legacyProductList({
122
+ ...opts,
123
+ leanVariants: true,
124
+ displayedVariantId: (items: any[]) => items[1].itemId,
125
+ })) as any[];
126
+ const kept = p.isVariantOf.hasVariant.find((v: any) => v.sku === "SKU2");
127
+ expect(ladderOf(kept).length).toBeGreaterThan(0);
128
+ // ...and it is the LEAN shape, not a second full product.
129
+ expect(kept.description).toBeUndefined();
130
+ });
131
+
132
+ it("priceSpecifications rewrites the root ladder with the caller's rule", async () => {
133
+ const [p] = (await legacyProductList({
134
+ ...opts,
135
+ priceSpecifications: (specs) => specs.filter((s) => !s.priceComponentType),
136
+ })) as any[];
137
+ expect(ladderOf(p).every((s: any) => !s.priceComponentType)).toBe(true);
138
+ expect(ladderOf(p).length).toBeLessThan(10);
139
+ });
140
+
141
+ it("maxImages caps image[] by position", async () => {
142
+ const [p] = (await legacyProductList({ ...opts, maxImages: 2 })) as any[];
143
+ expect(p.image).toHaveLength(2);
144
+ });
145
+
146
+ it("every option absent is byte-for-byte the previous output", async () => {
147
+ const [before] = (await legacyProductList(opts)) as any[];
148
+ const [after] = (await legacyProductList({
149
+ ...opts,
150
+ leanVariants: undefined,
151
+ displayedVariantId: undefined,
152
+ maxImages: undefined,
153
+ priceSpecifications: undefined,
154
+ })) as any[];
155
+ expect(JSON.stringify(after)).toBe(JSON.stringify(before));
156
+ });
157
+ });
@@ -4,7 +4,7 @@
4
4
  * Combines /autocomplete_suggestions/ and /product_search/ in parallel,
5
5
  * transforms IS products to schema.org via pickSku + toProduct.
6
6
  */
7
- import { getVtexConfig, intelligentSearch as vtexIS } from "../client";
7
+ import { getVtexConfig, intelligentSearch as vtexIS, storefrontBaseUrl } from "../client";
8
8
  import { pickSku, toProduct as toSchemaProduct } from "../utils/transform";
9
9
 
10
10
  export interface AutocompleteProps {
@@ -40,9 +40,7 @@ export async function autocompleteSearch(props: AutocompleteProps): Promise<Auto
40
40
  ]);
41
41
 
42
42
  const config = getVtexConfig();
43
- const baseUrl = config.publicUrl
44
- ? `https://${config.publicUrl}`
45
- : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
43
+ const baseUrl = storefrontBaseUrl(config);
46
44
 
47
45
  return {
48
46
  searches: suggestionsData.searches ?? [],
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ProductDetailsPage } from "@decocms/apps-commerce/types";
8
- import { getVtexConfig, vtexCachedFetch } from "../../client";
8
+ import { getVtexConfig, storefrontBaseUrl, vtexCachedFetch } from "../../client";
9
9
  import PDPDefaultPath from "../paths/PDPDefaultPath";
10
10
  import { searchBySlug } from "../../utils/slugCache";
11
11
  import { pickSku, toProductPage } from "../../utils/transform";
@@ -74,9 +74,7 @@ export default async function vtexProductDetailsPage(
74
74
  }
75
75
 
76
76
  const product = products[0];
77
- const baseUrl = config.publicUrl
78
- ? `https://${config.publicUrl}`
79
- : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
77
+ const baseUrl = storefrontBaseUrl(config);
80
78
 
81
79
  const sku = pickSku(product, skuId);
82
80
 
@@ -7,12 +7,25 @@
7
7
  */
8
8
 
9
9
  import type { Product } from "@decocms/apps-commerce/types";
10
- import { getVtexConfig, intelligentSearch, toFacetPath } from "../../client";
10
+ import { getVtexConfig, intelligentSearch, storefrontBaseUrl, toFacetPath } from "../../client";
11
11
  import { pickSku, sortProducts, toProductShelf } from "../../utils/transform";
12
12
  import type { Product as ProductVTEX } from "../../utils/types";
13
13
 
14
14
  export interface ProductListProps {
15
15
  props?: CollectionProps | QueryProps | ProductIDProps | FacetsProps;
16
+ /**
17
+ * Include every SKU as a lean variant in each product's isVariantOf.hasVariant[]
18
+ * so shelf cards can render the full size/color grid. Default false (lean shelf:
19
+ * a single in-stock variant per product).
20
+ */
21
+ completeVariants?: boolean;
22
+ /**
23
+ * `imageLabel`s that survive the shelf transform's 2-image cap. Storefronts
24
+ * that pick the card image by name (a still/packshot asset, a colour
25
+ * thumbnail) need this: those assets are registered last on the SKU, so the
26
+ * positional cap drops them and the card falls back to the first photo.
27
+ */
28
+ keepImageNames?: string[];
16
29
  }
17
30
 
18
31
  interface CollectionProps {
@@ -130,16 +143,19 @@ export default async function vtexProductListShelf(
130
143
  const data = await intelligentSearch<{ products: ProductVTEX[] }>(endpoint, params);
131
144
 
132
145
  const vtexProducts = data.products ?? [];
133
- const baseUrl = config.publicUrl
134
- ? `https://${config.publicUrl}`
135
- : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
146
+ const baseUrl = storefrontBaseUrl(config);
136
147
 
137
148
  let products = vtexProducts.map((p) => {
138
149
  const fetchedSkus = ids ? new Set(ids) : null;
139
150
  const preferredSku = fetchedSkus
140
151
  ? (p.items.find((item) => fetchedSkus.has(item.itemId)) ?? pickSku(p))
141
152
  : pickSku(p);
142
- return toProductShelf(p, preferredSku, 0, { baseUrl, priceCurrency: "BRL" });
153
+ return toProductShelf(p, preferredSku, 0, {
154
+ baseUrl,
155
+ priceCurrency: "BRL",
156
+ shelfCompleteVariants: props.completeVariants,
157
+ keepImageNames: props.keepImageNames,
158
+ });
143
159
  });
144
160
 
145
161
  if (ids) {
@@ -4,6 +4,7 @@ import {
4
4
  intelligentSearch,
5
5
  type PageType,
6
6
  pageTypesFromPath,
7
+ storefrontBaseUrl,
7
8
  toFacetPath,
8
9
  } from "../../client";
9
10
  import { pickSku, toProduct } from "../../utils/transform";
@@ -441,9 +442,7 @@ export default async function vtexProductListingPage(props: PLPProps): Promise<a
441
442
  const { products: vtexProducts, pagination, recordsFiltered } = productsResult;
442
443
 
443
444
  // 3. Transform products using shared transform pipeline (same as deco-cx/apps)
444
- const baseUrl = config.publicUrl
445
- ? `https://${config.publicUrl}`
446
- : `https://${config.account}.vtexcommercestable.com.br`;
445
+ const baseUrl = storefrontBaseUrl(config);
447
446
 
448
447
  const schemaProducts = (vtexProducts as ProductVTEX[]).map((p) => {
449
448
  const sku = pickSku(p);
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { Product, Suggestion } from "@decocms/apps-commerce/types";
7
- import { getVtexConfig, intelligentSearch } from "../../client";
7
+ import { getVtexConfig, intelligentSearch, storefrontBaseUrl } from "../../client";
8
8
  import { pickSku, toProduct } from "../../utils/transform";
9
9
  import type { Product as ProductVTEX } from "../../utils/types";
10
10
 
@@ -29,9 +29,7 @@ export default async function vtexSuggestions(props: SuggestionsProps): Promise<
29
29
  }));
30
30
 
31
31
  const config = getVtexConfig();
32
- const baseUrl = config.publicUrl
33
- ? `https://${config.publicUrl}`
34
- : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
32
+ const baseUrl = storefrontBaseUrl(config);
35
33
 
36
34
  const products: Product[] = (data.products ?? []).slice(0, props.count ?? 4).map((p) => {
37
35
  const sku = pickSku(p);
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { Product } from "@decocms/apps-commerce/types";
11
- import { getVtexConfig, vtexCachedFetch } from "../../client";
11
+ import { getVtexConfig, storefrontBaseUrl, vtexCachedFetch } from "../../client";
12
12
  import { resolveProductIdBySlug } from "../../utils/slugCache";
13
13
  import { pickSku, toProduct } from "../../utils/transform";
14
14
  import type { LegacyProduct } from "../../utils/types";
@@ -58,9 +58,7 @@ export default async function vtexRelatedProducts(
58
58
  if (!related?.length) return [];
59
59
 
60
60
  const config = getVtexConfig();
61
- const baseUrl = config.publicUrl
62
- ? `https://${config.publicUrl}`
63
- : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
61
+ const baseUrl = storefrontBaseUrl(config);
64
62
 
65
63
  let result = related.slice(0, count).map((p) => {
66
64
  const sku = pickSku(p);