@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/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.
|
|
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\.
|
|
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.
|
|
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\.
|
|
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. `
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
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
|
|
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);
|