@decocms/apps-vtex 7.2.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 +67 -0
- package/src/README.md +6 -0
- package/src/__tests__/client-segment-cookie.test.ts +255 -0
- package/src/__tests__/client-set-cookie-forward.test.ts +257 -0
- package/src/actions/address.ts +250 -0
- package/src/actions/analytics/sendEvent.ts +86 -0
- package/src/actions/auth.ts +333 -0
- package/src/actions/checkout.ts +522 -0
- package/src/actions/index.ts +11 -0
- package/src/actions/masterData.ts +168 -0
- package/src/actions/misc.ts +188 -0
- package/src/actions/newsletter.ts +105 -0
- package/src/actions/orders.ts +34 -0
- package/src/actions/profile.ts +201 -0
- package/src/actions/session.ts +85 -0
- package/src/actions/trigger.ts +43 -0
- package/src/actions/wishlist.ts +114 -0
- package/src/client.ts +667 -0
- package/src/commerceLoaders.ts +261 -0
- package/src/hooks/__tests__/createUseCart.test.ts +184 -0
- package/src/hooks/__tests__/createUseUser.test.ts +48 -0
- package/src/hooks/__tests__/createUseWishlist.test.ts +87 -0
- package/src/hooks/createUseCart.ts +360 -0
- package/src/hooks/createUseUser.ts +153 -0
- package/src/hooks/createUseWishlist.ts +242 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/useAutocomplete.ts +83 -0
- package/src/hooks/useCart.ts +243 -0
- package/src/hooks/useUser.ts +78 -0
- package/src/hooks/useWishlist.ts +119 -0
- package/src/index.ts +17 -0
- package/src/invoke.ts +181 -0
- package/src/loaders/ProductDetailsPage.ts +1 -0
- package/src/loaders/ProductList.ts +1 -0
- package/src/loaders/ProductListingPage.ts +1 -0
- package/src/loaders/address.ts +115 -0
- package/src/loaders/autocomplete.ts +58 -0
- package/src/loaders/brands.ts +44 -0
- package/src/loaders/cart.ts +52 -0
- package/src/loaders/catalog.ts +163 -0
- package/src/loaders/collections.ts +55 -0
- package/src/loaders/index.ts +19 -0
- package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +20 -0
- package/src/loaders/intelligentSearch/productDetailsPage.ts +95 -0
- package/src/loaders/intelligentSearch/productList.ts +154 -0
- package/src/loaders/intelligentSearch/productListingPage.ts +506 -0
- package/src/loaders/intelligentSearch/suggestions.ts +46 -0
- package/src/loaders/legacy/productDetailsPage.ts +1 -0
- package/src/loaders/legacy/productList.ts +1 -0
- package/src/loaders/legacy/relatedProductsLoader.ts +81 -0
- package/src/loaders/legacy.ts +635 -0
- package/src/loaders/logistics.ts +111 -0
- package/src/loaders/minicart.ts +78 -0
- package/src/loaders/navbar.ts +26 -0
- package/src/loaders/orders.ts +92 -0
- package/src/loaders/pageType.ts +68 -0
- package/src/loaders/payment.ts +97 -0
- package/src/loaders/productListFull.ts +159 -0
- package/src/loaders/profile.ts +138 -0
- package/src/loaders/promotion.ts +30 -0
- package/src/loaders/search.ts +124 -0
- package/src/loaders/session.ts +83 -0
- package/src/loaders/user.ts +87 -0
- package/src/loaders/wishlist.ts +89 -0
- package/src/loaders/wishlistProducts.ts +69 -0
- package/src/loaders/workflow/products.ts +64 -0
- package/src/loaders/workflow.ts +316 -0
- package/src/logo.png +0 -0
- package/src/middleware.ts +240 -0
- package/src/mod.ts +152 -0
- package/src/registry.ts +9 -0
- package/src/types.ts +248 -0
- package/src/utils/__tests__/cookieSanitizer.test.ts +162 -0
- package/src/utils/__tests__/fetch.test.ts +80 -0
- package/src/utils/__tests__/fetchCache.test.ts +112 -0
- package/src/utils/__tests__/instrumentedFetch.test.ts +158 -0
- package/src/utils/__tests__/intelligentSearch.test.ts +88 -0
- package/src/utils/__tests__/minicart.test.ts +184 -0
- package/src/utils/__tests__/operationRouter.test.ts +227 -0
- package/src/utils/__tests__/resourceRange.test.ts +32 -0
- package/src/utils/__tests__/sitemap.test.ts +185 -0
- package/src/utils/__tests__/slugify.test.ts +31 -0
- package/src/utils/__tests__/transform.test.ts +698 -0
- package/src/utils/accountLoaders.ts +203 -0
- package/src/utils/authHelpers.ts +85 -0
- package/src/utils/batch.ts +18 -0
- package/src/utils/cookieSanitizer.ts +173 -0
- package/src/utils/cookies.ts +167 -0
- package/src/utils/enrichment.ts +560 -0
- package/src/utils/fetch.ts +107 -0
- package/src/utils/fetchCache.ts +222 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/instrumentedFetch.ts +78 -0
- package/src/utils/intelligentSearch.ts +96 -0
- package/src/utils/legacy.ts +139 -0
- package/src/utils/minicart.ts +139 -0
- package/src/utils/operationRouter.ts +139 -0
- package/src/utils/pickAndOmit.ts +25 -0
- package/src/utils/proxy.ts +435 -0
- package/src/utils/resourceRange.ts +10 -0
- package/src/utils/segment.ts +152 -0
- package/src/utils/similars.ts +37 -0
- package/src/utils/sitemap.ts +282 -0
- package/src/utils/slugCache.ts +28 -0
- package/src/utils/slugify.ts +13 -0
- package/src/utils/transform.ts +1509 -0
- package/src/utils/types.ts +1884 -0
- package/src/utils/vtexId.ts +138 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Segment } from "./types";
|
|
2
|
+
|
|
3
|
+
const removeNonLatin1Chars = (str: string) => str.replace(/[^\x00-\xFF]/g, "");
|
|
4
|
+
|
|
5
|
+
export const SEGMENT_COOKIE_NAME = "vtex_segment";
|
|
6
|
+
export const SALES_CHANNEL_COOKIE = "VTEXSC";
|
|
7
|
+
|
|
8
|
+
export interface WrappedSegment {
|
|
9
|
+
payload: Partial<Segment>;
|
|
10
|
+
token: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_SEGMENT: Partial<Segment> = {
|
|
14
|
+
utmi_campaign: null,
|
|
15
|
+
utmi_page: null,
|
|
16
|
+
utmi_part: null,
|
|
17
|
+
utm_campaign: null,
|
|
18
|
+
utm_source: null,
|
|
19
|
+
utm_medium: null,
|
|
20
|
+
channel: "1",
|
|
21
|
+
cultureInfo: "pt-BR",
|
|
22
|
+
currencyCode: "BRL",
|
|
23
|
+
currencySymbol: "R$",
|
|
24
|
+
countryCode: "BRA",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stable serialization.
|
|
29
|
+
*
|
|
30
|
+
* Even if attributes are in a different order, the final segment
|
|
31
|
+
* value will be the same. This improves cache hits.
|
|
32
|
+
*/
|
|
33
|
+
export const serializeSegment = ({
|
|
34
|
+
campaigns,
|
|
35
|
+
channel,
|
|
36
|
+
priceTables,
|
|
37
|
+
regionId,
|
|
38
|
+
utm_campaign,
|
|
39
|
+
utm_source,
|
|
40
|
+
utm_medium,
|
|
41
|
+
utmi_campaign,
|
|
42
|
+
utmi_page,
|
|
43
|
+
utmi_part,
|
|
44
|
+
currencyCode,
|
|
45
|
+
currencySymbol,
|
|
46
|
+
countryCode,
|
|
47
|
+
cultureInfo,
|
|
48
|
+
channelPrivacy,
|
|
49
|
+
}: Partial<Segment>): string => {
|
|
50
|
+
const seg = {
|
|
51
|
+
campaigns,
|
|
52
|
+
channel,
|
|
53
|
+
priceTables,
|
|
54
|
+
regionId,
|
|
55
|
+
utm_campaign: utm_campaign && removeNonLatin1Chars(utm_campaign).replace(/[/[\]{}()<>.]/g, ""),
|
|
56
|
+
utm_source: utm_source && removeNonLatin1Chars(utm_source).replace(/[/[\]{}()<>.]/g, ""),
|
|
57
|
+
utm_medium: utm_medium && removeNonLatin1Chars(utm_medium).replace(/[/[\]{}()<>.]/g, ""),
|
|
58
|
+
utmi_campaign: utmi_campaign && removeNonLatin1Chars(utmi_campaign),
|
|
59
|
+
utmi_page: utmi_page && removeNonLatin1Chars(utmi_page),
|
|
60
|
+
utmi_part: utmi_part && removeNonLatin1Chars(utmi_part),
|
|
61
|
+
currencyCode,
|
|
62
|
+
currencySymbol,
|
|
63
|
+
countryCode,
|
|
64
|
+
cultureInfo,
|
|
65
|
+
channelPrivacy,
|
|
66
|
+
};
|
|
67
|
+
return btoa(JSON.stringify(seg));
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const parseSegment = (cookie: string): Partial<Segment> | null => {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(atob(cookie));
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const SEGMENT_QUERY_PARAMS = [
|
|
79
|
+
"utmi_campaign",
|
|
80
|
+
"utmi_page",
|
|
81
|
+
"utmi_part",
|
|
82
|
+
"utm_campaign",
|
|
83
|
+
"utm_source",
|
|
84
|
+
"utm_medium",
|
|
85
|
+
] as const;
|
|
86
|
+
|
|
87
|
+
export const buildSegmentFromParams = (searchParams: URLSearchParams): Partial<Segment> => {
|
|
88
|
+
const partialSegment: Partial<Segment> = {};
|
|
89
|
+
for (const qs of SEGMENT_QUERY_PARAMS) {
|
|
90
|
+
const param = searchParams.get(qs);
|
|
91
|
+
if (param) {
|
|
92
|
+
partialSegment[qs] = param;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sc = searchParams.get("sc");
|
|
97
|
+
if (sc) {
|
|
98
|
+
partialSegment.channel = sc;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return partialSegment;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const withSegmentCookie = (segment: WrappedSegment, headers?: Headers): Headers => {
|
|
105
|
+
const h = new Headers(headers);
|
|
106
|
+
if (!segment) return h;
|
|
107
|
+
|
|
108
|
+
h.set("cookie", `${SEGMENT_COOKIE_NAME}=${segment.token}`);
|
|
109
|
+
return h;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function getCookieValue(cookieHeader: string, name: string): string | null {
|
|
113
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
114
|
+
return match?.[1] ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build a complete segment from request cookies.
|
|
119
|
+
* Reads both vtex_segment and VTEXSC cookies.
|
|
120
|
+
* VTEXSC contains the sales channel and overrides the segment channel.
|
|
121
|
+
*/
|
|
122
|
+
export const buildSegmentFromCookies = (cookieHeader: string): Partial<Segment> => {
|
|
123
|
+
const segmentCookie = getCookieValue(cookieHeader, SEGMENT_COOKIE_NAME);
|
|
124
|
+
const vtexsc = getCookieValue(cookieHeader, SALES_CHANNEL_COOKIE);
|
|
125
|
+
|
|
126
|
+
const base = segmentCookie ? parseSegment(segmentCookie) : null;
|
|
127
|
+
const segment: Partial<Segment> = { ...DEFAULT_SEGMENT, ...base };
|
|
128
|
+
|
|
129
|
+
if (vtexsc) {
|
|
130
|
+
segment.channel = vtexsc;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return segment;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if the current segment represents an anonymous user
|
|
138
|
+
* (no campaigns, no UTMs, no regionId, no custom priceTables).
|
|
139
|
+
*/
|
|
140
|
+
export const isAnonymous = (segment: Partial<Segment>): boolean => {
|
|
141
|
+
return (
|
|
142
|
+
!segment.campaigns &&
|
|
143
|
+
!segment.utm_campaign &&
|
|
144
|
+
!segment.utm_source &&
|
|
145
|
+
!segment.utm_medium &&
|
|
146
|
+
!segment.utmi_campaign &&
|
|
147
|
+
!segment.utmi_page &&
|
|
148
|
+
!segment.utmi_part &&
|
|
149
|
+
!segment.regionId &&
|
|
150
|
+
!segment.priceTables
|
|
151
|
+
);
|
|
152
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Product } from "@decocms/apps-commerce/types";
|
|
2
|
+
import { getVtexConfig, vtexFetch } from "../client";
|
|
3
|
+
import { pickSku, toProduct } from "./transform";
|
|
4
|
+
import type { LegacyProduct } from "./types";
|
|
5
|
+
|
|
6
|
+
export const withIsSimilarTo = async (product: Product): Promise<Product> => {
|
|
7
|
+
const id = product.isVariantOf?.productGroupID ?? product.inProductGroupWithID;
|
|
8
|
+
|
|
9
|
+
if (!id) {
|
|
10
|
+
return product;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const rawSimilars = await vtexFetch<LegacyProduct[]>(
|
|
15
|
+
`/api/catalog_system/pub/products/crossselling/similars/${id}`,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
if (!rawSimilars?.length) return product;
|
|
19
|
+
|
|
20
|
+
const config = getVtexConfig();
|
|
21
|
+
const baseUrl = config.publicUrl
|
|
22
|
+
? `https://${config.publicUrl}`
|
|
23
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
24
|
+
|
|
25
|
+
const similars = rawSimilars.map((p) => {
|
|
26
|
+
const sku = pickSku(p);
|
|
27
|
+
return toProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...product,
|
|
32
|
+
isSimilarTo: similars,
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return product;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX Sitemap utilities.
|
|
3
|
+
*
|
|
4
|
+
* Two flavors:
|
|
5
|
+
* - `getVtexSitemapEntries()` — flatten VTEX sub-sitemaps into a single
|
|
6
|
+
* `SitemapEntry[]` list, for composition with the CMS sitemap generator.
|
|
7
|
+
* - `createVtexSitemapProxy()` — proxy `/sitemap.xml` and `/sitemap/*`
|
|
8
|
+
* straight from VTEX's commerce-stable origin, preserving the sitemap-index
|
|
9
|
+
* shape (so crawlers stay within Google's per-file size limit). This is the
|
|
10
|
+
* right choice when the storefront has no native sitemap renderer and just
|
|
11
|
+
* needs to expose VTEX's existing crawl tree to the public hostname.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getVtexConfig, vtexFetchResponse, vtexHost } from "../client";
|
|
15
|
+
|
|
16
|
+
export interface SitemapEntry {
|
|
17
|
+
loc: string;
|
|
18
|
+
lastmod?: string;
|
|
19
|
+
changefreq?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
|
20
|
+
priority?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fetch sitemap entries from VTEX's sitemap API.
|
|
25
|
+
*
|
|
26
|
+
* VTEX exposes /sitemap.xml which contains links to sub-sitemaps
|
|
27
|
+
* (products, categories, brands, etc.). This function fetches the
|
|
28
|
+
* main sitemap index and extracts all <loc> entries from the
|
|
29
|
+
* referenced sub-sitemaps.
|
|
30
|
+
*
|
|
31
|
+
* @param origin - The storefront origin (e.g., "https://www.mystore.com")
|
|
32
|
+
* @param options.maxDepth - How many levels of sub-sitemaps to follow (default: 1)
|
|
33
|
+
* @param options.rewriteHost - Whether to rewrite VTEX hostnames to the storefront origin (default: true)
|
|
34
|
+
*/
|
|
35
|
+
export async function getVtexSitemapEntries(
|
|
36
|
+
origin: string,
|
|
37
|
+
options?: {
|
|
38
|
+
maxDepth?: number;
|
|
39
|
+
rewriteHost?: boolean;
|
|
40
|
+
includeBrands?: boolean;
|
|
41
|
+
includeCategories?: boolean;
|
|
42
|
+
includeProducts?: boolean;
|
|
43
|
+
},
|
|
44
|
+
): Promise<SitemapEntry[]> {
|
|
45
|
+
const config = getVtexConfig();
|
|
46
|
+
const vtexSitemapHost = vtexHost("vtexcommercestable", config);
|
|
47
|
+
const rewrite = options?.rewriteHost !== false;
|
|
48
|
+
const includeProducts = options?.includeProducts !== false;
|
|
49
|
+
const includeCategories = options?.includeCategories !== false;
|
|
50
|
+
const includeBrands = options?.includeBrands !== false;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const mainSitemapUrl = `https://${vtexSitemapHost}/sitemap.xml`;
|
|
54
|
+
const mainResponse = await vtexFetchResponse(mainSitemapUrl);
|
|
55
|
+
const mainXml = await mainResponse.text();
|
|
56
|
+
|
|
57
|
+
const subSitemapUrls = extractLocs(mainXml);
|
|
58
|
+
const entries: SitemapEntry[] = [];
|
|
59
|
+
|
|
60
|
+
const filteredUrls = subSitemapUrls.filter((url) => {
|
|
61
|
+
const lower = url.toLowerCase();
|
|
62
|
+
if (!includeProducts && lower.includes("product")) return false;
|
|
63
|
+
if (!includeCategories && lower.includes("categor")) return false;
|
|
64
|
+
if (!includeBrands && lower.includes("brand")) return false;
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const maxDepth = options?.maxDepth ?? 1;
|
|
69
|
+
if (maxDepth < 1) {
|
|
70
|
+
return filteredUrls.map((url) => ({
|
|
71
|
+
loc: rewrite ? rewriteUrl(url, vtexSitemapHost, origin) : url,
|
|
72
|
+
changefreq: "daily" as const,
|
|
73
|
+
priority: 0.5,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const settled = await Promise.allSettled(
|
|
78
|
+
filteredUrls.map(async (subUrl) => {
|
|
79
|
+
try {
|
|
80
|
+
const resp = await vtexFetchResponse(subUrl);
|
|
81
|
+
const xml = await resp.text();
|
|
82
|
+
return extractLocs(xml);
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const today = new Date().toISOString().split("T")[0];
|
|
90
|
+
|
|
91
|
+
for (const result of settled) {
|
|
92
|
+
if (result.status !== "fulfilled") continue;
|
|
93
|
+
for (const loc of result.value) {
|
|
94
|
+
entries.push({
|
|
95
|
+
loc: rewrite ? rewriteUrl(loc, vtexSitemapHost, origin) : loc,
|
|
96
|
+
lastmod: today,
|
|
97
|
+
changefreq: "daily",
|
|
98
|
+
priority: 0.5,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return entries;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error("[VTEX Sitemap] Failed to fetch VTEX sitemap:", error);
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractLocs(xml: string): string[] {
|
|
111
|
+
const locs: string[] = [];
|
|
112
|
+
const regex = /<loc>\s*(.*?)\s*<\/loc>/g;
|
|
113
|
+
let match: RegExpExecArray | null;
|
|
114
|
+
while ((match = regex.exec(xml)) !== null) {
|
|
115
|
+
if (match[1]) locs.push(match[1].trim());
|
|
116
|
+
}
|
|
117
|
+
return locs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function rewriteUrl(url: string, vtexSitemapHost: string, origin: string): string {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = new URL(url);
|
|
123
|
+
const originParsed = new URL(origin);
|
|
124
|
+
const config = getVtexConfig();
|
|
125
|
+
const domain = config.domain ?? "com.br";
|
|
126
|
+
if (
|
|
127
|
+
parsed.hostname === vtexSitemapHost ||
|
|
128
|
+
parsed.hostname.endsWith(`.vtexcommercestable.${domain}`)
|
|
129
|
+
) {
|
|
130
|
+
parsed.protocol = originParsed.protocol;
|
|
131
|
+
parsed.hostname = originParsed.hostname;
|
|
132
|
+
parsed.port = originParsed.port;
|
|
133
|
+
}
|
|
134
|
+
return parsed.toString();
|
|
135
|
+
} catch {
|
|
136
|
+
return url.replace(`https://${vtexSitemapHost}`, origin);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// VTEX sitemap proxy factory
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Returns true if `pathname` is one of the proxied sitemap paths
|
|
146
|
+
* (`/sitemap.xml` or any `/sitemap/*` sub-sitemap).
|
|
147
|
+
*/
|
|
148
|
+
export function isVtexSitemapPath(pathname: string): boolean {
|
|
149
|
+
return pathname === "/sitemap.xml" || pathname.startsWith("/sitemap/");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface VtexSitemapProxyConfig {
|
|
153
|
+
/**
|
|
154
|
+
* Extra `<sitemap>` entries to inject into the root sitemap index
|
|
155
|
+
* (`/sitemap.xml` only — sub-sitemaps are passed through untouched).
|
|
156
|
+
*
|
|
157
|
+
* Useful for site-managed sitemaps such as a static search-result
|
|
158
|
+
* index (`sitemap-busca.xml`) that VTEX doesn't generate.
|
|
159
|
+
*
|
|
160
|
+
* Each value is normalized to an absolute URL on the storefront
|
|
161
|
+
* origin: leading-slash paths become `${origin}${path}`, and bare
|
|
162
|
+
* names become `${origin}/${name}`. Absolute URLs are used as-is.
|
|
163
|
+
*
|
|
164
|
+
* @example ["/sitemap-busca.xml"]
|
|
165
|
+
*/
|
|
166
|
+
extraSitemaps?: string[];
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* VTEX environment for the upstream sitemap fetch.
|
|
170
|
+
* @default "vtexcommercestable"
|
|
171
|
+
*/
|
|
172
|
+
environment?: "vtexcommercestable" | "vtexcommercebeta";
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `Cache-Control` header to set on proxied responses. The default
|
|
176
|
+
* favors edge caching (Cloudflare honors `s-maxage`) with a long
|
|
177
|
+
* stale-while-revalidate window so a slow VTEX origin never blocks
|
|
178
|
+
* crawlers.
|
|
179
|
+
*
|
|
180
|
+
* @default "public, s-maxage=3600, stale-while-revalidate=86400"
|
|
181
|
+
*/
|
|
182
|
+
cacheControl?: string;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Optional fetch override — primarily for tests. Defaults to the
|
|
186
|
+
* platform `fetch`.
|
|
187
|
+
*/
|
|
188
|
+
fetchImpl?: typeof fetch;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const DEFAULT_SITEMAP_CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400";
|
|
192
|
+
|
|
193
|
+
function normalizeExtraSitemap(entry: string, origin: string): string {
|
|
194
|
+
if (entry.startsWith("http://") || entry.startsWith("https://")) return entry;
|
|
195
|
+
const path = entry.startsWith("/") ? entry : `/${entry}`;
|
|
196
|
+
return `${origin}${path}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Creates a sitemap proxy handler that mirrors VTEX's `/sitemap.xml`
|
|
201
|
+
* (and sub-sitemaps) onto the storefront origin.
|
|
202
|
+
*
|
|
203
|
+
* Returns a function compatible with `createDecoWorkerEntry`'s
|
|
204
|
+
* `proxyHandler`: it returns `null` for non-sitemap paths, so it
|
|
205
|
+
* composes naturally with other proxy handlers
|
|
206
|
+
* (`createVtexCheckoutProxy`, custom logic, etc.).
|
|
207
|
+
*
|
|
208
|
+
* The VTEX account is read from the `configureVtex(...)` call done at
|
|
209
|
+
* worker startup — no per-call account configuration is needed.
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```ts
|
|
213
|
+
* import { createVtexSitemapProxy } from "@decocms/apps/vtex/utils/sitemap";
|
|
214
|
+
* import {
|
|
215
|
+
* createVtexCheckoutProxy,
|
|
216
|
+
* shouldProxyToVtex,
|
|
217
|
+
* } from "@decocms/apps/vtex/utils/proxy";
|
|
218
|
+
*
|
|
219
|
+
* const proxySitemap = createVtexSitemapProxy({
|
|
220
|
+
* extraSitemaps: ["/sitemap-busca.xml"], // optional, site-managed
|
|
221
|
+
* });
|
|
222
|
+
* const proxyCheckout = createVtexCheckoutProxy({ ... });
|
|
223
|
+
*
|
|
224
|
+
* createDecoWorkerEntry(serverEntry, {
|
|
225
|
+
* proxyHandler: async (request, url) => {
|
|
226
|
+
* const sitemap = await proxySitemap(request, url);
|
|
227
|
+
* if (sitemap) return sitemap;
|
|
228
|
+
*
|
|
229
|
+
* if (!shouldProxyToVtex(url.pathname)) return null;
|
|
230
|
+
* return proxyCheckout(request, url);
|
|
231
|
+
* },
|
|
232
|
+
* });
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
export function createVtexSitemapProxy(
|
|
236
|
+
config: VtexSitemapProxyConfig = {},
|
|
237
|
+
): (request: Request, url: URL) => Promise<Response | null> {
|
|
238
|
+
const environment = config.environment ?? "vtexcommercestable";
|
|
239
|
+
const cacheControl = config.cacheControl ?? DEFAULT_SITEMAP_CACHE_CONTROL;
|
|
240
|
+
const extraSitemaps = config.extraSitemaps ?? [];
|
|
241
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
242
|
+
|
|
243
|
+
return async (_request: Request, url: URL): Promise<Response | null> => {
|
|
244
|
+
if (!isVtexSitemapPath(url.pathname)) return null;
|
|
245
|
+
|
|
246
|
+
// vtexHost() reads the configured account from configureVtex().
|
|
247
|
+
const vtexSitemapHost = vtexHost(environment);
|
|
248
|
+
const target = `https://${vtexSitemapHost}${url.pathname}`;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const resp = await fetchImpl(target);
|
|
252
|
+
if (!resp.ok) {
|
|
253
|
+
console.error(`[vtex-sitemap] VTEX returned ${resp.status} for ${url.pathname}`);
|
|
254
|
+
return new Response("Sitemap temporarily unavailable", { status: 502 });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let xml = await resp.text();
|
|
258
|
+
xml = xml.replaceAll(`https://${vtexSitemapHost}`, url.origin);
|
|
259
|
+
|
|
260
|
+
if (url.pathname === "/sitemap.xml" && extraSitemaps.length > 0) {
|
|
261
|
+
const extraEntries = extraSitemaps
|
|
262
|
+
.map(
|
|
263
|
+
(s) =>
|
|
264
|
+
` <sitemap>\n <loc>${normalizeExtraSitemap(s, url.origin)}</loc>\n </sitemap>`,
|
|
265
|
+
)
|
|
266
|
+
.join("\n");
|
|
267
|
+
xml = xml.replace("</sitemapindex>", `${extraEntries}\n</sitemapindex>`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return new Response(xml, {
|
|
271
|
+
status: 200,
|
|
272
|
+
headers: {
|
|
273
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
274
|
+
"Cache-Control": cacheControl,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error("[vtex-sitemap] Failed to proxy VTEX sitemap:", err);
|
|
279
|
+
return new Response("Sitemap temporarily unavailable", { status: 502 });
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-flight dedup + SWR cache for VTEX Legacy Catalog slug→product lookups.
|
|
3
|
+
*
|
|
4
|
+
* Multiple loaders on the same page (PDP, relatedProducts x3, BuyTogether)
|
|
5
|
+
* all call `/api/catalog_system/pub/products/search/{slug}/p` for the same slug.
|
|
6
|
+
* This module routes through vtexCachedFetch which provides in-flight dedup
|
|
7
|
+
* and stale-while-revalidate caching (3 min TTL for 200 responses).
|
|
8
|
+
*/
|
|
9
|
+
import { getVtexConfig, vtexCachedFetch } from "../client";
|
|
10
|
+
import type { LegacyProduct } from "./types";
|
|
11
|
+
|
|
12
|
+
export function searchBySlug(linkText: string): Promise<LegacyProduct[] | null> {
|
|
13
|
+
const config = getVtexConfig();
|
|
14
|
+
const sc = config.salesChannel;
|
|
15
|
+
const scParam = sc ? `?sc=${sc}` : "";
|
|
16
|
+
|
|
17
|
+
return vtexCachedFetch<LegacyProduct[]>(
|
|
18
|
+
`/api/catalog_system/pub/products/search/${encodeURIComponent(linkText)}/p${scParam}`,
|
|
19
|
+
).catch((err) => {
|
|
20
|
+
console.error(`[VTEX] searchBySlug error for "${linkText}":`, err);
|
|
21
|
+
return null;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function resolveProductIdBySlug(linkText: string): Promise<string | null> {
|
|
26
|
+
const products = await searchBySlug(linkText);
|
|
27
|
+
return products?.length ? products[0].productId : null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const mapped = JSON.parse(
|
|
2
|
+
`{"Á":"A","Ä":"A","Â":"A","À":"A","Ã":"A","Å":"A","Č":"C","Ç":"C","Ć":"C","Ď":"D","É":"E","Ě":"E","Ë":"E","È":"E","Ê":"E","Ẽ":"E","Ĕ":"E","Ȇ":"E","Í":"I","Ì":"I","Î":"I","Ï":"I","Ň":"N","Ñ":"N","Ó":"O","Ö":"O","Ò":"O","Ô":"O","Õ":"O","Ø":"O","Ř":"R","Ŕ":"R","Š":"S","Ť":"T","Ú":"U","Ů":"U","Ü":"U","Ù":"U","Û":"U","Ý":"Y","Ÿ":"Y","Ž":"Z","á":"a","ä":"a","â":"a","à":"a","ã":"a","å":"a","č":"c","ç":"c","ć":"c","ď":"d","é":"e","ě":"e","ë":"e","è":"e","ê":"e","ẽ":"e","ĕ":"e","ȇ":"e","í":"i","ì":"i","î":"i","ï":"i","ň":"n","ñ":"n","ó":"o","ö":"o","ò":"o","ô":"o","õ":"o","ø":"o","ð":"o","ř":"r","ŕ":"r","š":"s","ť":"t","ú":"u","ů":"u","ü":"u","ù":"u","û":"u","ý":"y","ÿ":"y","ž":"z","þ":"b","Þ":"B","Đ":"D","đ":"d","ß":"B","Æ":"A","a":"a"}`,
|
|
3
|
+
);
|
|
4
|
+
|
|
5
|
+
export const slugify = (str: string) =>
|
|
6
|
+
str
|
|
7
|
+
.replace(/,/g, "")
|
|
8
|
+
.replace(/[·/_,:]/g, "-")
|
|
9
|
+
.replace(/[*+~.()'"!:@&[\]`/ %$#?{}|><=_^]/g, "-")
|
|
10
|
+
.split("")
|
|
11
|
+
.map((char) => mapped[char] ?? char)
|
|
12
|
+
.join("")
|
|
13
|
+
.toLowerCase();
|