@decocms/apps-vtex 7.66.4 → 7.66.6
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/client.ts +49 -0
- package/src/loaders/autocomplete.ts +2 -4
- package/src/loaders/intelligentSearch/productDetailsPage.ts +2 -4
- package/src/loaders/intelligentSearch/productList.ts +10 -4
- 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/productListFull.ts +2 -4
- package/src/loaders/workflow/products.ts +2 -4
- package/src/utils/__tests__/storefrontBaseUrl.test.ts +61 -0
- package/src/utils/__tests__/transform.test.ts +80 -0
- package/src/utils/enrichment.ts +3 -7
- package/src/utils/similars.ts +2 -4
- package/src/utils/transform.ts +48 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-vtex",
|
|
3
|
-
"version": "7.66.
|
|
3
|
+
"version": "7.66.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco commerce app: VTEX integration",
|
|
6
6
|
"repository": {
|
|
@@ -51,10 +51,10 @@
|
|
|
51
51
|
"lint:unused": "knip"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@decocms/blocks": "7.66.
|
|
55
|
-
"@decocms/apps-commerce": "7.66.
|
|
56
|
-
"@decocms/apps-website": "7.66.
|
|
57
|
-
"@decocms/tanstack": "7.66.
|
|
54
|
+
"@decocms/blocks": "7.66.6",
|
|
55
|
+
"@decocms/apps-commerce": "7.66.6",
|
|
56
|
+
"@decocms/apps-website": "7.66.6",
|
|
57
|
+
"@decocms/tanstack": "7.66.6"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@tanstack/react-query": ">=5.0.0",
|
package/src/client.ts
CHANGED
|
@@ -206,6 +206,55 @@ export function vtexHost(environment: string = "vtexcommercestable", config?: Vt
|
|
|
206
206
|
return `${c.account}.${environment}.${domain}`;
|
|
207
207
|
}
|
|
208
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
|
+
|
|
209
258
|
function baseUrl(): string {
|
|
210
259
|
return `https://${vtexHost()}`;
|
|
211
260
|
}
|
|
@@ -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,7 +7,7 @@
|
|
|
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
|
|
|
@@ -19,6 +19,13 @@ export interface ProductListProps {
|
|
|
19
19
|
* a single in-stock variant per product).
|
|
20
20
|
*/
|
|
21
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[];
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
interface CollectionProps {
|
|
@@ -136,9 +143,7 @@ export default async function vtexProductListShelf(
|
|
|
136
143
|
const data = await intelligentSearch<{ products: ProductVTEX[] }>(endpoint, params);
|
|
137
144
|
|
|
138
145
|
const vtexProducts = data.products ?? [];
|
|
139
|
-
const baseUrl = config
|
|
140
|
-
? `https://${config.publicUrl}`
|
|
141
|
-
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
146
|
+
const baseUrl = storefrontBaseUrl(config);
|
|
142
147
|
|
|
143
148
|
let products = vtexProducts.map((p) => {
|
|
144
149
|
const fetchedSkus = ids ? new Set(ids) : null;
|
|
@@ -149,6 +154,7 @@ export default async function vtexProductListShelf(
|
|
|
149
154
|
baseUrl,
|
|
150
155
|
priceCurrency: "BRL",
|
|
151
156
|
shelfCompleteVariants: props.completeVariants,
|
|
157
|
+
keepImageNames: props.keepImageNames,
|
|
152
158
|
});
|
|
153
159
|
});
|
|
154
160
|
|
|
@@ -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);
|
|
@@ -7,7 +7,7 @@
|
|
|
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, toProduct } from "../utils/transform";
|
|
12
12
|
import type { Product as ProductVTEX } from "../utils/types";
|
|
13
13
|
|
|
@@ -135,9 +135,7 @@ export default async function vtexProductList(props: ProductListProps): Promise<
|
|
|
135
135
|
const data = await intelligentSearch<{ products: ProductVTEX[] }>(endpoint, params);
|
|
136
136
|
|
|
137
137
|
const vtexProducts = data.products ?? [];
|
|
138
|
-
const baseUrl = config
|
|
139
|
-
? `https://${config.publicUrl}`
|
|
140
|
-
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
138
|
+
const baseUrl = storefrontBaseUrl(config);
|
|
141
139
|
|
|
142
140
|
let products = vtexProducts.map((p) => {
|
|
143
141
|
const fetchedSkus = ids ? new Set(ids) : null;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Product } from "@decocms/apps-commerce/types";
|
|
7
|
-
import { getVtexConfig, intelligentSearch, toFacetPath } from "../../client";
|
|
7
|
+
import { getVtexConfig, intelligentSearch, storefrontBaseUrl, toFacetPath } from "../../client";
|
|
8
8
|
import { pickSku, toProduct } from "../../utils/transform";
|
|
9
9
|
import type { Product as ProductVTEX } from "../../utils/types";
|
|
10
10
|
|
|
@@ -49,9 +49,7 @@ export default async function vtexWorkflowProducts(
|
|
|
49
49
|
const data = await intelligentSearch<{ products: ProductVTEX[] }>(endpoint, params);
|
|
50
50
|
|
|
51
51
|
const products = data.products ?? [];
|
|
52
|
-
const baseUrl = config
|
|
53
|
-
? `https://${config.publicUrl}`
|
|
54
|
-
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
52
|
+
const baseUrl = storefrontBaseUrl(config);
|
|
55
53
|
|
|
56
54
|
return products.map((p) => {
|
|
57
55
|
const sku = pickSku(p);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { configureVtex, storefrontBaseUrl } from "../../client";
|
|
5
|
+
|
|
6
|
+
const ACCOUNT = { account: "examplestore" } as const;
|
|
7
|
+
|
|
8
|
+
describe("storefrontBaseUrl", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
configureVtex({ ...ACCOUNT });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("uses the origin of the request being served", () => {
|
|
14
|
+
configureVtex({ ...ACCOUNT, publicUrl: "https://secure.example.com/" });
|
|
15
|
+
|
|
16
|
+
const origin = RequestContext.run(new Request("https://www.example.com/camisas?page=2"), () =>
|
|
17
|
+
storefrontBaseUrl(),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(origin).toBe("https://www.example.com");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("never returns the double-scheme origin a full publicUrl used to produce", () => {
|
|
24
|
+
// `https://${publicUrl}` on "https://secure.example.com/" parsed with
|
|
25
|
+
// host "https", so every product URL came out as https://https/<slug>/p.
|
|
26
|
+
configureVtex({ ...ACCOUNT, publicUrl: "https://secure.example.com/" });
|
|
27
|
+
|
|
28
|
+
const origin = storefrontBaseUrl();
|
|
29
|
+
|
|
30
|
+
expect(origin).toBe("https://secure.example.com");
|
|
31
|
+
expect(new URL("/camisa-paris/p", origin).href).toBe("https://secure.example.com/camisa-paris/p");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("accepts a publicUrl stored without a scheme", () => {
|
|
35
|
+
configureVtex({ ...ACCOUNT, publicUrl: "secure.example.com" });
|
|
36
|
+
|
|
37
|
+
expect(storefrontBaseUrl()).toBe("https://secure.example.com");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("falls back to the account host when publicUrl is absent", () => {
|
|
41
|
+
expect(storefrontBaseUrl()).toBe("https://examplestore.vtexcommercestable.com.br");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("honours a configured domain in the account-host fallback", () => {
|
|
45
|
+
configureVtex({ ...ACCOUNT, domain: "com" });
|
|
46
|
+
|
|
47
|
+
expect(storefrontBaseUrl()).toBe("https://examplestore.vtexcommercestable.com");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("falls back to the account host rather than publish an unparseable publicUrl", () => {
|
|
51
|
+
configureVtex({ ...ACCOUNT, publicUrl: "://" });
|
|
52
|
+
|
|
53
|
+
expect(storefrontBaseUrl()).toBe("https://examplestore.vtexcommercestable.com.br");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns an origin with no trailing slash", () => {
|
|
57
|
+
configureVtex({ ...ACCOUNT, publicUrl: "https://secure.example.com/" });
|
|
58
|
+
|
|
59
|
+
expect(storefrontBaseUrl()).not.toMatch(/\/$/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -749,6 +749,7 @@ describe("toProductShelf", () => {
|
|
|
749
749
|
},
|
|
750
750
|
],
|
|
751
751
|
variations: [{ name: "Cor", values: ["Preto"] }],
|
|
752
|
+
...overrides,
|
|
752
753
|
}) as any;
|
|
753
754
|
|
|
754
755
|
// Campanha is a PRODUCT specification (in specificationGroups), NOT a sku.variation.
|
|
@@ -818,6 +819,69 @@ describe("toProductShelf", () => {
|
|
|
818
819
|
expect.arrayContaining([expect.objectContaining({ name: "RefId", value: "REF-SKU1" })]),
|
|
819
820
|
);
|
|
820
821
|
});
|
|
822
|
+
|
|
823
|
+
// A real catalog registers the packshot and the colour thumbnail LAST:
|
|
824
|
+
// measured on a live store, `secondary` sits at index 6 and `color-thumbnail`
|
|
825
|
+
// at index 7 of 8. A card that selects either BY NAME gets nothing from a
|
|
826
|
+
// positional cap and silently falls back to the first photo (the model
|
|
827
|
+
// wearing the piece).
|
|
828
|
+
const labelledImages = [
|
|
829
|
+
...Array.from({ length: 6 }, (_, i) => ({
|
|
830
|
+
imageUrl: `https://img.com/photo-${i}.jpg`,
|
|
831
|
+
imageText: `Photo ${i}`,
|
|
832
|
+
imageLabel: `product-photo-${i}`,
|
|
833
|
+
})),
|
|
834
|
+
{ imageUrl: "https://img.com/still.jpg", imageText: "Still", imageLabel: "secondary" },
|
|
835
|
+
{ imageUrl: "https://img.com/thumb.jpg", imageText: "Thumb", imageLabel: "color-thumbnail" },
|
|
836
|
+
];
|
|
837
|
+
const skuWithLabels = () => makeSku({ images: labelledImages });
|
|
838
|
+
const labelledProduct = () => makeProduct({ items: [skuWithLabels()] });
|
|
839
|
+
|
|
840
|
+
it("keeps the 2-image cap when keepImageNames is absent", () => {
|
|
841
|
+
const result = toProductShelf(labelledProduct(), skuWithLabels(), 0, options);
|
|
842
|
+
expect(result.image?.map((i) => i.name)).toEqual(["product-photo-0", "product-photo-1"]);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it("keepImageNames adds back labelled images that fall outside the cap", () => {
|
|
846
|
+
const result = toProductShelf(labelledProduct(), skuWithLabels(), 0, {
|
|
847
|
+
...options,
|
|
848
|
+
keepImageNames: ["secondary", "color-thumbnail"],
|
|
849
|
+
});
|
|
850
|
+
expect(result.image?.map((i) => i.name)).toEqual([
|
|
851
|
+
"product-photo-0",
|
|
852
|
+
"product-photo-1",
|
|
853
|
+
"secondary",
|
|
854
|
+
"color-thumbnail",
|
|
855
|
+
]);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it("keepImageNames does not duplicate a label already inside the cap", () => {
|
|
859
|
+
const result = toProductShelf(labelledProduct(), skuWithLabels(), 0, {
|
|
860
|
+
...options,
|
|
861
|
+
keepImageNames: ["product-photo-0", "secondary"],
|
|
862
|
+
});
|
|
863
|
+
expect(result.image?.map((i) => i.name)).toEqual([
|
|
864
|
+
"product-photo-0",
|
|
865
|
+
"product-photo-1",
|
|
866
|
+
"secondary",
|
|
867
|
+
]);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("keepImageNames listing a label the SKU does not have changes nothing", () => {
|
|
871
|
+
const result = toProductShelf(labelledProduct(), skuWithLabels(), 0, {
|
|
872
|
+
...options,
|
|
873
|
+
keepImageNames: ["vira"],
|
|
874
|
+
});
|
|
875
|
+
expect(result.image?.map((i) => i.name)).toEqual(["product-photo-0", "product-photo-1"]);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("maxImages overrides the shelf default cap", () => {
|
|
879
|
+
const result = toProductShelf(labelledProduct(), skuWithLabels(), 0, {
|
|
880
|
+
...options,
|
|
881
|
+
maxImages: 3,
|
|
882
|
+
});
|
|
883
|
+
expect(result.image).toHaveLength(3);
|
|
884
|
+
});
|
|
821
885
|
});
|
|
822
886
|
|
|
823
887
|
// ---------------------------------------------------------------------------
|
|
@@ -1018,12 +1082,28 @@ describe("toProduct — displayedVariantId / maxImages", () => {
|
|
|
1018
1082
|
);
|
|
1019
1083
|
});
|
|
1020
1084
|
|
|
1085
|
+
it("keepImageNames rescues a labelled image from the maxImages cap", () => {
|
|
1086
|
+
const capped = toProduct(product, items[0], 0, {
|
|
1087
|
+
...options,
|
|
1088
|
+
maxImages: 2,
|
|
1089
|
+
keepImageNames: ["label3"],
|
|
1090
|
+
});
|
|
1091
|
+
expect(capped.image?.map((i) => i.name)).toEqual(["label0", "label1", "label3"]);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it("keepImageNames without maxImages is a no-op — every image is already there", () => {
|
|
1095
|
+
const before = toProduct(product, items[0], 0, options);
|
|
1096
|
+
const after = toProduct(product, items[0], 0, { ...options, keepImageNames: ["label3"] });
|
|
1097
|
+
expect(JSON.stringify(after)).toBe(JSON.stringify(before));
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1021
1100
|
it("every option absent is byte-for-byte the previous output", () => {
|
|
1022
1101
|
const before = toProduct(product, items[0], 0, options);
|
|
1023
1102
|
const after = toProduct(product, items[0], 0, {
|
|
1024
1103
|
...options,
|
|
1025
1104
|
displayedVariantId: undefined,
|
|
1026
1105
|
maxImages: undefined,
|
|
1106
|
+
keepImageNames: undefined,
|
|
1027
1107
|
priceSpecifications: undefined,
|
|
1028
1108
|
});
|
|
1029
1109
|
expect(JSON.stringify(after)).toBe(JSON.stringify(before));
|
package/src/utils/enrichment.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import type { Product, ProductLeaf } from "@decocms/apps-commerce/types";
|
|
27
|
-
import { getVtexConfig, vtexFetch, vtexIOGraphQL } from "../client";
|
|
27
|
+
import { getVtexConfig, storefrontBaseUrl, vtexFetch, vtexIOGraphQL } from "../client";
|
|
28
28
|
import { listBrands } from "../loaders/brands";
|
|
29
29
|
import { batch } from "./batch";
|
|
30
30
|
import { withIsSimilarTo } from "./similars";
|
|
@@ -372,9 +372,7 @@ export function withKitItems(): ProductEnricher {
|
|
|
372
372
|
if (!productIDs.size) return products;
|
|
373
373
|
|
|
374
374
|
const config = getVtexConfig();
|
|
375
|
-
const baseUrl = config
|
|
376
|
-
? `https://${config.publicUrl}`
|
|
377
|
-
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
375
|
+
const baseUrl = storefrontBaseUrl(config);
|
|
378
376
|
|
|
379
377
|
const batches = batch([...productIDs], KIT_ITEMS_BATCH_SIZE);
|
|
380
378
|
const productsById = new Map<string, ProductLeaf>();
|
|
@@ -428,9 +426,7 @@ export function withVariants(): ProductEnricher {
|
|
|
428
426
|
if (!productIDs.size) return products;
|
|
429
427
|
|
|
430
428
|
const config = getVtexConfig();
|
|
431
|
-
const baseUrl = config
|
|
432
|
-
? `https://${config.publicUrl}`
|
|
433
|
-
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
429
|
+
const baseUrl = storefrontBaseUrl(config);
|
|
434
430
|
|
|
435
431
|
const batches = batch([...productIDs], VARIANTS_BATCH_SIZE);
|
|
436
432
|
const productsById = new Map<string, Product>();
|
package/src/utils/similars.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Product } from "@decocms/apps-commerce/types";
|
|
2
|
-
import { getVtexConfig, vtexFetch } from "../client";
|
|
2
|
+
import { getVtexConfig, storefrontBaseUrl, vtexFetch } from "../client";
|
|
3
3
|
import { pickSku, toProduct } from "./transform";
|
|
4
4
|
import type { LegacyProduct } from "./types";
|
|
5
5
|
|
|
@@ -18,9 +18,7 @@ export const withIsSimilarTo = async (product: Product): Promise<Product> => {
|
|
|
18
18
|
if (!rawSimilars?.length) return product;
|
|
19
19
|
|
|
20
20
|
const config = getVtexConfig();
|
|
21
|
-
const baseUrl = config
|
|
22
|
-
? `https://${config.publicUrl}`
|
|
23
|
-
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
21
|
+
const baseUrl = storefrontBaseUrl(config);
|
|
24
22
|
|
|
25
23
|
const similars = rawSimilars.map((p) => {
|
|
26
24
|
const sku = pickSku(p);
|
package/src/utils/transform.ts
CHANGED
|
@@ -168,9 +168,24 @@ export interface ProductOptions {
|
|
|
168
168
|
* (hover) image sits at index 1 in only 5 of the 19 products that have one —
|
|
169
169
|
* index 2 in 12 of them, index 3 in 2. A cap of 2 therefore drops the hover
|
|
170
170
|
* image on most cards that use one. Callers that select by name should keep
|
|
171
|
-
* the named entries
|
|
171
|
+
* the named entries through {@link keepImageNames}.
|
|
172
172
|
*/
|
|
173
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[];
|
|
174
189
|
/** Property names to keep on lean variant additionalProperty. Defaults to VARIANT_PROPERTY_NAMES. */
|
|
175
190
|
variantPropertyNames?: Set<string>;
|
|
176
191
|
/** When leanVariants is true, still include image[0] on each variant entry. Default true. */
|
|
@@ -186,6 +201,28 @@ export interface ProductOptions {
|
|
|
186
201
|
shelfCompleteVariants?: boolean;
|
|
187
202
|
}
|
|
188
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
|
+
|
|
189
226
|
/** Returns first available sku */
|
|
190
227
|
const findFirstAvailable = (items: Array<LegacySkuVTEX | SkuVTEX>) =>
|
|
191
228
|
items?.find((item) =>
|
|
@@ -495,8 +532,7 @@ export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
|
495
532
|
} satisfies ProductGroup)
|
|
496
533
|
: undefined;
|
|
497
534
|
|
|
498
|
-
const cappedImages =
|
|
499
|
-
typeof options.maxImages === "number" ? images?.slice(0, options.maxImages) : images;
|
|
535
|
+
const cappedImages = capImages(images, options.maxImages, options.keepImageNames);
|
|
500
536
|
const finalImages = cappedImages?.map(({ imageUrl, imageText, imageLabel }) => {
|
|
501
537
|
const url = imagesByKey.get(getImageKey(imageUrl)) ?? imageUrl;
|
|
502
538
|
const alternateName = imageText || imageLabel || "";
|
|
@@ -659,6 +695,9 @@ const applyPriceSpecifications = (offers: Offer[], options: ProductOptions): Off
|
|
|
659
695
|
}))
|
|
660
696
|
: offers;
|
|
661
697
|
|
|
698
|
+
/** Default positional image cap for the shelf transform (front + back). */
|
|
699
|
+
const SHELF_MAX_IMAGES = 2;
|
|
700
|
+
|
|
662
701
|
/** Property names commonly used by ProductCard/Shelf components */
|
|
663
702
|
const SHELF_PROPERTY_NAMES = new Set([
|
|
664
703
|
"category",
|
|
@@ -674,7 +713,8 @@ const SHELF_PROPERTY_NAMES = new Set([
|
|
|
674
713
|
* Lean product transform for shelf/card display. Same signature as toProduct().
|
|
675
714
|
*
|
|
676
715
|
* Differences from toProduct():
|
|
677
|
-
* - 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
|
|
678
718
|
* - Offers: best seller only (in-stock first, then cheapest), stripped installments (keeps ListPrice, SalePrice, SRP, PIX, best no-interest)
|
|
679
719
|
* - isVariantOf: single in-stock variant at level 0
|
|
680
720
|
* - additionalProperty: filtered to known-used property names
|
|
@@ -690,9 +730,11 @@ export const toProductShelf = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
|
690
730
|
const { productId, items, productReference } = product;
|
|
691
731
|
const { name, itemId: skuId } = sku;
|
|
692
732
|
|
|
693
|
-
// Images: cap at 2
|
|
733
|
+
// Images: cap at 2, keeping any label the caller selects by name
|
|
694
734
|
const rawImages = nonEmptyArray(sku.images);
|
|
695
|
-
const
|
|
735
|
+
const cappedImages =
|
|
736
|
+
capImages(rawImages, options.maxImages ?? SHELF_MAX_IMAGES, options.keepImageNames) ?? [];
|
|
737
|
+
const mappedImages = cappedImages.map(({ imageUrl, imageText, imageLabel }) => ({
|
|
696
738
|
"@type": "ImageObject" as const,
|
|
697
739
|
alternateName: imageText || imageLabel || "",
|
|
698
740
|
url: imageUrl,
|