@decocms/apps 0.20.1
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/.github/workflows/release.yml +34 -0
- package/.releaserc.json +25 -0
- package/commerce/components/Image.tsx +209 -0
- package/commerce/components/JsonLd.tsx +285 -0
- package/commerce/sdk/analytics.ts +24 -0
- package/commerce/sdk/formatPrice.ts +23 -0
- package/commerce/sdk/url.ts +9 -0
- package/commerce/sdk/useOffer.ts +75 -0
- package/commerce/sdk/useVariantPossibilities.ts +43 -0
- package/commerce/types/commerce.ts +1105 -0
- package/commerce/utils/canonical.ts +11 -0
- package/commerce/utils/constants.ts +9 -0
- package/commerce/utils/filters.ts +10 -0
- package/commerce/utils/productToAnalyticsItem.ts +67 -0
- package/commerce/utils/stateByZip.ts +50 -0
- package/knip.json +19 -0
- package/package.json +77 -0
- package/shopify/actions/cart/addItems.ts +37 -0
- package/shopify/actions/cart/updateCoupons.ts +32 -0
- package/shopify/actions/cart/updateItems.ts +32 -0
- package/shopify/actions/user/signIn.ts +45 -0
- package/shopify/actions/user/signUp.ts +36 -0
- package/shopify/client.ts +58 -0
- package/shopify/index.ts +32 -0
- package/shopify/init.ts +40 -0
- package/shopify/loaders/ProductDetailsPage.ts +35 -0
- package/shopify/loaders/ProductList.ts +101 -0
- package/shopify/loaders/ProductListingPage.ts +180 -0
- package/shopify/loaders/RelatedProducts.ts +45 -0
- package/shopify/loaders/cart.ts +73 -0
- package/shopify/loaders/shop.ts +40 -0
- package/shopify/loaders/user.ts +44 -0
- package/shopify/utils/admin/admin.ts +57 -0
- package/shopify/utils/admin/queries.ts +29 -0
- package/shopify/utils/cart.ts +28 -0
- package/shopify/utils/cookies.ts +85 -0
- package/shopify/utils/enums.ts +438 -0
- package/shopify/utils/graphql.ts +69 -0
- package/shopify/utils/storefront/queries.ts +530 -0
- package/shopify/utils/storefront/storefront.graphql.gen.ts +113 -0
- package/shopify/utils/transform.ts +436 -0
- package/shopify/utils/types.ts +191 -0
- package/shopify/utils/user.ts +23 -0
- package/shopify/utils/utils.ts +164 -0
- package/tsconfig.json +11 -0
- package/vtex/README.md +6 -0
- package/vtex/actions/address.ts +211 -0
- package/vtex/actions/auth.ts +337 -0
- package/vtex/actions/checkout.ts +497 -0
- package/vtex/actions/index.ts +11 -0
- package/vtex/actions/masterData.ts +170 -0
- package/vtex/actions/misc.ts +196 -0
- package/vtex/actions/newsletter.ts +108 -0
- package/vtex/actions/orders.ts +37 -0
- package/vtex/actions/profile.ts +119 -0
- package/vtex/actions/session.ts +87 -0
- package/vtex/actions/trigger.ts +43 -0
- package/vtex/actions/wishlist.ts +116 -0
- package/vtex/client.ts +423 -0
- package/vtex/hooks/index.ts +4 -0
- package/vtex/hooks/useAutocomplete.ts +89 -0
- package/vtex/hooks/useCart.ts +219 -0
- package/vtex/hooks/useUser.ts +78 -0
- package/vtex/hooks/useWishlist.ts +119 -0
- package/vtex/index.ts +14 -0
- package/vtex/inline-loaders/productDetailsPage.ts +75 -0
- package/vtex/inline-loaders/productList.ts +163 -0
- package/vtex/inline-loaders/productListingPage.ts +447 -0
- package/vtex/inline-loaders/relatedProducts.ts +83 -0
- package/vtex/inline-loaders/suggestions.ts +49 -0
- package/vtex/inline-loaders/workflowProducts.ts +68 -0
- package/vtex/invoke.ts +202 -0
- package/vtex/loaders/address.ts +120 -0
- package/vtex/loaders/brands.ts +51 -0
- package/vtex/loaders/cart.ts +49 -0
- package/vtex/loaders/catalog.ts +165 -0
- package/vtex/loaders/collections.ts +57 -0
- package/vtex/loaders/index.ts +19 -0
- package/vtex/loaders/legacy.ts +671 -0
- package/vtex/loaders/logistics.ts +115 -0
- package/vtex/loaders/navbar.ts +29 -0
- package/vtex/loaders/orders.ts +103 -0
- package/vtex/loaders/pageType.ts +62 -0
- package/vtex/loaders/payment.ts +107 -0
- package/vtex/loaders/profile.ts +138 -0
- package/vtex/loaders/promotion.ts +33 -0
- package/vtex/loaders/search.ts +127 -0
- package/vtex/loaders/session.ts +91 -0
- package/vtex/loaders/user.ts +89 -0
- package/vtex/loaders/wishlist.ts +89 -0
- package/vtex/loaders/wishlistProducts.ts +81 -0
- package/vtex/loaders/workflow.ts +323 -0
- package/vtex/logo.png +0 -0
- package/vtex/middleware.ts +229 -0
- package/vtex/types.ts +248 -0
- package/vtex/utils/batch.ts +21 -0
- package/vtex/utils/cookies.ts +76 -0
- package/vtex/utils/enrichment.ts +540 -0
- package/vtex/utils/fetchCache.ts +150 -0
- package/vtex/utils/index.ts +17 -0
- package/vtex/utils/intelligentSearch.ts +84 -0
- package/vtex/utils/legacy.ts +155 -0
- package/vtex/utils/pickAndOmit.ts +30 -0
- package/vtex/utils/proxy.ts +196 -0
- package/vtex/utils/resourceRange.ts +10 -0
- package/vtex/utils/segment.ts +163 -0
- package/vtex/utils/similars.ts +38 -0
- package/vtex/utils/sitemap.ts +133 -0
- package/vtex/utils/slugCache.ts +32 -0
- package/vtex/utils/slugify.ts +13 -0
- package/vtex/utils/transform.ts +1331 -0
- package/vtex/utils/types.ts +1884 -0
- package/vtex/utils/vtexId.ts +103 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { vtexFetch } from "../client";
|
|
2
|
+
import type {
|
|
3
|
+
PageType,
|
|
4
|
+
SelectedFacet,
|
|
5
|
+
SimulationBehavior,
|
|
6
|
+
Sort,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
export const SESSION_COOKIE = "vtex_is_session";
|
|
10
|
+
export const ANONYMOUS_COOKIE = "vtex_is_anonymous";
|
|
11
|
+
|
|
12
|
+
export const withDefaultFacets = (
|
|
13
|
+
allFacets: readonly SelectedFacet[],
|
|
14
|
+
) => {
|
|
15
|
+
return [...allFacets];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const toPath = (facets: SelectedFacet[]) =>
|
|
19
|
+
facets.map(({ key, value }) => key ? `${key}/${value}` : value).join("/");
|
|
20
|
+
|
|
21
|
+
interface Params {
|
|
22
|
+
query: string;
|
|
23
|
+
page: number;
|
|
24
|
+
count: number;
|
|
25
|
+
sort: Sort;
|
|
26
|
+
fuzzy: string;
|
|
27
|
+
locale: string;
|
|
28
|
+
hideUnavailableItems: boolean;
|
|
29
|
+
simulationBehavior: SimulationBehavior;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const withDefaultParams = ({
|
|
33
|
+
query = "",
|
|
34
|
+
page = 0,
|
|
35
|
+
count = 12,
|
|
36
|
+
sort = "",
|
|
37
|
+
fuzzy = "auto",
|
|
38
|
+
locale = "pt-BR",
|
|
39
|
+
hideUnavailableItems,
|
|
40
|
+
simulationBehavior = "default",
|
|
41
|
+
}: Partial<Params>) => ({
|
|
42
|
+
page: page + 1,
|
|
43
|
+
count,
|
|
44
|
+
query,
|
|
45
|
+
sort,
|
|
46
|
+
...(fuzzy ? { fuzzy } : {}),
|
|
47
|
+
locale,
|
|
48
|
+
hideUnavailableItems: hideUnavailableItems ?? false,
|
|
49
|
+
simulationBehavior,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const isFilterParam = (keyFilter: string): boolean =>
|
|
53
|
+
keyFilter.startsWith("filter.");
|
|
54
|
+
|
|
55
|
+
const segmentsFromTerm = (term: string) => term.split("/").filter(Boolean);
|
|
56
|
+
|
|
57
|
+
const segmentsFromSearchParams = (url: string) => {
|
|
58
|
+
const searchParams = new URLSearchParams(url).entries();
|
|
59
|
+
|
|
60
|
+
const categories = Array.from(searchParams).sort()
|
|
61
|
+
.reduce((acc, [key, value]) => {
|
|
62
|
+
if (key.includes("filter.category")) {
|
|
63
|
+
acc.push(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return acc;
|
|
67
|
+
}, [] as string[]);
|
|
68
|
+
|
|
69
|
+
return categories.length ? categories : segmentsFromTerm(url);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const pageTypesFromUrl = async (
|
|
73
|
+
url: string,
|
|
74
|
+
): Promise<PageType[]> => {
|
|
75
|
+
const segments = segmentsFromSearchParams(url);
|
|
76
|
+
|
|
77
|
+
return await Promise.all(
|
|
78
|
+
segments.map((_, index) =>
|
|
79
|
+
vtexFetch<PageType>(
|
|
80
|
+
`/api/catalog_system/pub/portal/pagetype/${segments.slice(0, index + 1).join("/")}`,
|
|
81
|
+
)
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Seo } from "../../commerce/types/commerce";
|
|
2
|
+
import { vtexFetch } from "../client";
|
|
3
|
+
import { slugify } from "./slugify";
|
|
4
|
+
import type { PageType } from "./types";
|
|
5
|
+
import { WrappedSegment } from "./segment";
|
|
6
|
+
|
|
7
|
+
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
8
|
+
|
|
9
|
+
export const toSegmentParams = (
|
|
10
|
+
{ payload: segment }: WrappedSegment,
|
|
11
|
+
) => (Object.fromEntries(
|
|
12
|
+
Object.entries({
|
|
13
|
+
utmi_campaign: segment.utmi_campaign ?? undefined,
|
|
14
|
+
utm_campaign: segment.utm_campaign ?? undefined,
|
|
15
|
+
utm_source: segment.utm_source ?? undefined,
|
|
16
|
+
sc: segment.channel ?? undefined,
|
|
17
|
+
}).filter(([_, v]) => v != undefined),
|
|
18
|
+
));
|
|
19
|
+
|
|
20
|
+
const PAGE_TYPE_TO_MAP_PARAM = {
|
|
21
|
+
Brand: "b",
|
|
22
|
+
Category: "c",
|
|
23
|
+
Department: "c",
|
|
24
|
+
SubCategory: "c",
|
|
25
|
+
Collection: "productClusterIds",
|
|
26
|
+
Cluster: "productClusterIds",
|
|
27
|
+
Search: "ft",
|
|
28
|
+
FullText: "ft",
|
|
29
|
+
Product: "p",
|
|
30
|
+
NotFound: null,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const segmentsFromTerm = (term: string) => term.split("/").filter(Boolean);
|
|
34
|
+
|
|
35
|
+
export const getValidTypesFromPageTypes = (pagetypes: PageType[]) => {
|
|
36
|
+
return pagetypes
|
|
37
|
+
.filter((type) => PAGE_TYPE_TO_MAP_PARAM[type.pageType]);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const pageTypesFromPathname = async (
|
|
41
|
+
term: string,
|
|
42
|
+
): Promise<PageType[]> => {
|
|
43
|
+
const segments = segmentsFromTerm(term);
|
|
44
|
+
|
|
45
|
+
return await Promise.all(
|
|
46
|
+
segments.map((_, index) =>
|
|
47
|
+
vtexFetch<PageType>(
|
|
48
|
+
`/api/catalog_system/pub/portal/pagetype/${segments.slice(0, index + 1).join("/")}`,
|
|
49
|
+
)
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const getMapAndTerm = (
|
|
55
|
+
pageTypes: PageType[],
|
|
56
|
+
) => {
|
|
57
|
+
const term = pageTypes
|
|
58
|
+
.map((type, index) =>
|
|
59
|
+
type.url
|
|
60
|
+
? segmentsFromTerm(
|
|
61
|
+
new URL(`http://${type.url}`).pathname,
|
|
62
|
+
)[index]
|
|
63
|
+
: null
|
|
64
|
+
)
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join("/");
|
|
67
|
+
|
|
68
|
+
const map = pageTypes
|
|
69
|
+
.map((type) => PAGE_TYPE_TO_MAP_PARAM[type.pageType])
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join(",");
|
|
72
|
+
|
|
73
|
+
if (map === "ft" && term === "s") {
|
|
74
|
+
return ["", ""];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [map, term];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const pageTypesToBreadcrumbList = (
|
|
81
|
+
pages: PageType[],
|
|
82
|
+
baseUrl: string,
|
|
83
|
+
) => {
|
|
84
|
+
const filteredPages = pages
|
|
85
|
+
.filter(({ pageType }) =>
|
|
86
|
+
pageType === "Category" || pageType === "Department" ||
|
|
87
|
+
pageType === "SubCategory"
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return filteredPages.map((page, index) => {
|
|
91
|
+
const position = index + 1;
|
|
92
|
+
const slug = filteredPages.slice(0, position).map((x) => slugify(x.name!));
|
|
93
|
+
|
|
94
|
+
return ({
|
|
95
|
+
"@type": "ListItem" as const,
|
|
96
|
+
name: page.name!,
|
|
97
|
+
item: new URL(`/${slug.join("/")}`, baseUrl).href,
|
|
98
|
+
position,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const pageTypesToSeo = (
|
|
104
|
+
pages: PageType[],
|
|
105
|
+
baseUrl: string,
|
|
106
|
+
currentPage?: number,
|
|
107
|
+
): Seo | null => {
|
|
108
|
+
const current = pages.at(-1);
|
|
109
|
+
const url = new URL(baseUrl);
|
|
110
|
+
const fullTextSearch = url.searchParams.get("q");
|
|
111
|
+
const hasMapTermOrSkuId =
|
|
112
|
+
!!(url.searchParams.get("map") || url.searchParams.get("skuId"));
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
(!current || current.pageType === "Search" ||
|
|
116
|
+
current.pageType === "FullText") && fullTextSearch
|
|
117
|
+
) {
|
|
118
|
+
return {
|
|
119
|
+
title: capitalize(fullTextSearch),
|
|
120
|
+
description: capitalize(fullTextSearch),
|
|
121
|
+
canonical: url.href,
|
|
122
|
+
noIndexing: hasMapTermOrSkuId,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!current) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
title: current.title || current.name || "",
|
|
132
|
+
description: current.metaTagDescription!,
|
|
133
|
+
noIndexing: hasMapTermOrSkuId,
|
|
134
|
+
canonical: toCanonical(
|
|
135
|
+
new URL(
|
|
136
|
+
(current.url && current.pageType !== "Collection")
|
|
137
|
+
? current.url.replace(/^[^/]*\//, "/")
|
|
138
|
+
.toLowerCase()
|
|
139
|
+
: url,
|
|
140
|
+
url,
|
|
141
|
+
),
|
|
142
|
+
currentPage,
|
|
143
|
+
),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function toCanonical(url: URL, page?: number) {
|
|
148
|
+
if (typeof page === "number") {
|
|
149
|
+
url.searchParams.set("page", `${page}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return url.href;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export { isFilterParam } from "./intelligentSearch";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function pick<
|
|
2
|
+
T extends object,
|
|
3
|
+
K extends keyof T = keyof T,
|
|
4
|
+
>(
|
|
5
|
+
keys: K[],
|
|
6
|
+
obj: T | null | undefined,
|
|
7
|
+
): Pick<T, K> | null {
|
|
8
|
+
if (!keys.length || !obj) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const entries = keys.map((key) => [key, obj[key]]);
|
|
13
|
+
|
|
14
|
+
return Object.fromEntries(entries);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function omit<T extends object, K extends keyof T>(
|
|
18
|
+
keys: K[],
|
|
19
|
+
obj: T | null | undefined,
|
|
20
|
+
): Omit<T, K> | null {
|
|
21
|
+
if (!keys.length || !obj) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pickedKeys = (Object.keys(obj) as K[]).filter(
|
|
26
|
+
(key) => !keys.includes(key),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return pick(pickedKeys, obj) as unknown as Omit<T, K>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX Proxy Utility.
|
|
3
|
+
*
|
|
4
|
+
* Proxies storefront requests for /checkout, /account, /api, /files, /arquivos
|
|
5
|
+
* to the VTEX origin. Essential for checkout and My Account pages to work.
|
|
6
|
+
*
|
|
7
|
+
* Designed to be used with TanStack Start API routes or Cloudflare Worker
|
|
8
|
+
* fetch handlers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getVtexConfig, vtexHost, type VtexConfig } from "../client";
|
|
12
|
+
import { proxySetCookie } from "./cookies";
|
|
13
|
+
|
|
14
|
+
export interface VtexProxyOptions {
|
|
15
|
+
/**
|
|
16
|
+
* VTEX environment suffix.
|
|
17
|
+
* @default "vtexcommercestable"
|
|
18
|
+
*/
|
|
19
|
+
environment?: "vtexcommercestable" | "vtexcommercebeta";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Additional path prefixes to proxy beyond the defaults.
|
|
23
|
+
* Example: ["/custom-api/"]
|
|
24
|
+
*/
|
|
25
|
+
extraPaths?: string[];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Paths that should NOT be proxied even if they match a prefix.
|
|
29
|
+
*/
|
|
30
|
+
excludePaths?: string[];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether to rewrite Set-Cookie domains to the storefront's domain.
|
|
34
|
+
* @default true
|
|
35
|
+
*/
|
|
36
|
+
rewriteCookieDomain?: boolean;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Custom headers to inject into every proxied request.
|
|
40
|
+
*/
|
|
41
|
+
extraHeaders?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_PROXY_PATHS = [
|
|
45
|
+
"/checkout",
|
|
46
|
+
"/checkout/",
|
|
47
|
+
"/account",
|
|
48
|
+
"/account/",
|
|
49
|
+
"/api/",
|
|
50
|
+
"/files/",
|
|
51
|
+
"/arquivos/",
|
|
52
|
+
"/checkout/changeToAnonymousUser/",
|
|
53
|
+
"/_v/",
|
|
54
|
+
"/no-cache/",
|
|
55
|
+
"/graphql/",
|
|
56
|
+
"/login",
|
|
57
|
+
"/login/",
|
|
58
|
+
"/logout",
|
|
59
|
+
"/logout/",
|
|
60
|
+
"/assets/",
|
|
61
|
+
"/_secure/account",
|
|
62
|
+
"/XMLData/",
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
66
|
+
"connection",
|
|
67
|
+
"keep-alive",
|
|
68
|
+
"proxy-authenticate",
|
|
69
|
+
"proxy-authorization",
|
|
70
|
+
"te",
|
|
71
|
+
"trailers",
|
|
72
|
+
"transfer-encoding",
|
|
73
|
+
"upgrade",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns all path prefixes that should be proxied to VTEX.
|
|
78
|
+
*/
|
|
79
|
+
export function getVtexProxyPaths(options?: VtexProxyOptions): string[] {
|
|
80
|
+
return [...DEFAULT_PROXY_PATHS, ...(options?.extraPaths ?? [])];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if a request path should be proxied to VTEX.
|
|
85
|
+
*/
|
|
86
|
+
export function shouldProxyToVtex(
|
|
87
|
+
pathname: string,
|
|
88
|
+
options?: VtexProxyOptions,
|
|
89
|
+
): boolean {
|
|
90
|
+
const paths = getVtexProxyPaths(options);
|
|
91
|
+
const excluded = options?.excludePaths ?? [];
|
|
92
|
+
|
|
93
|
+
if (excluded.some((ex) => pathname.startsWith(ex))) return false;
|
|
94
|
+
return paths.some((prefix) => pathname.startsWith(prefix));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildOriginUrl(
|
|
98
|
+
request: Request,
|
|
99
|
+
config: VtexConfig,
|
|
100
|
+
environment: string,
|
|
101
|
+
): URL {
|
|
102
|
+
const url = new URL(request.url);
|
|
103
|
+
const originHost = vtexHost(environment, config);
|
|
104
|
+
return new URL(`https://${originHost}${url.pathname}${url.search}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function filterHeaders(headers: Headers): Headers {
|
|
108
|
+
const filtered = new Headers();
|
|
109
|
+
headers.forEach((value, key) => {
|
|
110
|
+
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
|
|
111
|
+
filtered.set(key, value);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return filtered;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Proxy a request to VTEX origin.
|
|
119
|
+
*
|
|
120
|
+
* Forwards the request with all cookies and headers, rewrites
|
|
121
|
+
* Set-Cookie domains on the response, and strips hop-by-hop headers.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```ts
|
|
125
|
+
* // In a TanStack Start API route or catch-all handler
|
|
126
|
+
* if (shouldProxyToVtex(url.pathname)) {
|
|
127
|
+
* return proxyToVtex(request);
|
|
128
|
+
* }
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export async function proxyToVtex(
|
|
132
|
+
request: Request,
|
|
133
|
+
options?: VtexProxyOptions,
|
|
134
|
+
): Promise<Response> {
|
|
135
|
+
const config = getVtexConfig();
|
|
136
|
+
const environment = options?.environment ?? "vtexcommercestable";
|
|
137
|
+
|
|
138
|
+
const originUrl = buildOriginUrl(request, config, environment);
|
|
139
|
+
const forwardHeaders = filterHeaders(new Headers(request.headers));
|
|
140
|
+
|
|
141
|
+
forwardHeaders.set("Host", originUrl.hostname);
|
|
142
|
+
forwardHeaders.set("X-Forwarded-Host", new URL(request.url).hostname);
|
|
143
|
+
forwardHeaders.set("X-Forwarded-Proto", "https");
|
|
144
|
+
|
|
145
|
+
if (options?.extraHeaders) {
|
|
146
|
+
for (const [k, v] of Object.entries(options.extraHeaders)) {
|
|
147
|
+
forwardHeaders.set(k, v);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (config.appKey && config.appToken) {
|
|
152
|
+
forwardHeaders.set("X-VTEX-API-AppKey", config.appKey);
|
|
153
|
+
forwardHeaders.set("X-VTEX-API-AppToken", config.appToken);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const init: RequestInit = {
|
|
157
|
+
method: request.method,
|
|
158
|
+
headers: forwardHeaders,
|
|
159
|
+
redirect: "manual",
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
163
|
+
init.body = request.body;
|
|
164
|
+
// @ts-expect-error -- needed for streaming body in Workers
|
|
165
|
+
init.duplex = "half";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const originResponse = await fetch(originUrl.toString(), init);
|
|
169
|
+
|
|
170
|
+
const responseHeaders = filterHeaders(new Headers(originResponse.headers));
|
|
171
|
+
|
|
172
|
+
if (options?.rewriteCookieDomain !== false) {
|
|
173
|
+
proxySetCookie(
|
|
174
|
+
originResponse.headers,
|
|
175
|
+
responseHeaders,
|
|
176
|
+
new URL(request.url).origin,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (originResponse.status >= 300 && originResponse.status < 400) {
|
|
181
|
+
const location = originResponse.headers.get("location");
|
|
182
|
+
if (location) {
|
|
183
|
+
const originVtexHost = vtexHost(environment, config);
|
|
184
|
+
const storefrontOrigin = new URL(request.url).origin;
|
|
185
|
+
const vtexOrigin = `https://${originVtexHost}`;
|
|
186
|
+
const rewritten = location.replace(vtexOrigin, storefrontOrigin);
|
|
187
|
+
responseHeaders.set("location", rewritten);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return new Response(originResponse.body, {
|
|
192
|
+
status: originResponse.status,
|
|
193
|
+
statusText: originResponse.statusText,
|
|
194
|
+
headers: responseHeaders,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build REST-Range header values for VTEX paginated APIs.
|
|
3
|
+
* Ported from deco-cx/apps vtex/utils/resourceRange.ts
|
|
4
|
+
*/
|
|
5
|
+
export function resourceRange(skip: number, take: number) {
|
|
6
|
+
const from = Math.max(skip, 0);
|
|
7
|
+
const to = from + Math.min(100, take);
|
|
8
|
+
|
|
9
|
+
return { from, to };
|
|
10
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Segment } from "./types";
|
|
2
|
+
|
|
3
|
+
const removeNonLatin1Chars = (str: string) =>
|
|
4
|
+
str.replace(/[^\x00-\xFF]/g, "");
|
|
5
|
+
|
|
6
|
+
export const SEGMENT_COOKIE_NAME = "vtex_segment";
|
|
7
|
+
export const SALES_CHANNEL_COOKIE = "VTEXSC";
|
|
8
|
+
|
|
9
|
+
export interface WrappedSegment {
|
|
10
|
+
payload: Partial<Segment>;
|
|
11
|
+
token: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_SEGMENT: Partial<Segment> = {
|
|
15
|
+
utmi_campaign: null,
|
|
16
|
+
utmi_page: null,
|
|
17
|
+
utmi_part: null,
|
|
18
|
+
utm_campaign: null,
|
|
19
|
+
utm_source: null,
|
|
20
|
+
utm_medium: null,
|
|
21
|
+
channel: "1",
|
|
22
|
+
cultureInfo: "pt-BR",
|
|
23
|
+
currencyCode: "BRL",
|
|
24
|
+
currencySymbol: "R$",
|
|
25
|
+
countryCode: "BRA",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stable serialization.
|
|
30
|
+
*
|
|
31
|
+
* Even if attributes are in a different order, the final segment
|
|
32
|
+
* value will be the same. This improves cache hits.
|
|
33
|
+
*/
|
|
34
|
+
export const serializeSegment = ({
|
|
35
|
+
campaigns,
|
|
36
|
+
channel,
|
|
37
|
+
priceTables,
|
|
38
|
+
regionId,
|
|
39
|
+
utm_campaign,
|
|
40
|
+
utm_source,
|
|
41
|
+
utm_medium,
|
|
42
|
+
utmi_campaign,
|
|
43
|
+
utmi_page,
|
|
44
|
+
utmi_part,
|
|
45
|
+
currencyCode,
|
|
46
|
+
currencySymbol,
|
|
47
|
+
countryCode,
|
|
48
|
+
cultureInfo,
|
|
49
|
+
channelPrivacy,
|
|
50
|
+
}: Partial<Segment>): string => {
|
|
51
|
+
const seg = {
|
|
52
|
+
campaigns,
|
|
53
|
+
channel,
|
|
54
|
+
priceTables,
|
|
55
|
+
regionId,
|
|
56
|
+
utm_campaign: utm_campaign &&
|
|
57
|
+
removeNonLatin1Chars(utm_campaign).replace(/[\/\[\]{}()<>.]/g, ""),
|
|
58
|
+
utm_source: utm_source &&
|
|
59
|
+
removeNonLatin1Chars(utm_source).replace(/[\/\[\]{}()<>.]/g, ""),
|
|
60
|
+
utm_medium: utm_medium &&
|
|
61
|
+
removeNonLatin1Chars(utm_medium).replace(/[\/\[\]{}()<>.]/g, ""),
|
|
62
|
+
utmi_campaign: utmi_campaign && removeNonLatin1Chars(utmi_campaign),
|
|
63
|
+
utmi_page: utmi_page && removeNonLatin1Chars(utmi_page),
|
|
64
|
+
utmi_part: utmi_part && removeNonLatin1Chars(utmi_part),
|
|
65
|
+
currencyCode,
|
|
66
|
+
currencySymbol,
|
|
67
|
+
countryCode,
|
|
68
|
+
cultureInfo,
|
|
69
|
+
channelPrivacy,
|
|
70
|
+
};
|
|
71
|
+
return btoa(JSON.stringify(seg));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const parseSegment = (cookie: string): Partial<Segment> | null => {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(atob(cookie));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const SEGMENT_QUERY_PARAMS = [
|
|
83
|
+
"utmi_campaign",
|
|
84
|
+
"utmi_page",
|
|
85
|
+
"utmi_part",
|
|
86
|
+
"utm_campaign",
|
|
87
|
+
"utm_source",
|
|
88
|
+
"utm_medium",
|
|
89
|
+
] as const;
|
|
90
|
+
|
|
91
|
+
export const buildSegmentFromParams = (
|
|
92
|
+
searchParams: URLSearchParams,
|
|
93
|
+
): Partial<Segment> => {
|
|
94
|
+
const partialSegment: Partial<Segment> = {};
|
|
95
|
+
for (const qs of SEGMENT_QUERY_PARAMS) {
|
|
96
|
+
const param = searchParams.get(qs);
|
|
97
|
+
if (param) {
|
|
98
|
+
partialSegment[qs] = param;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sc = searchParams.get("sc");
|
|
103
|
+
if (sc) {
|
|
104
|
+
partialSegment.channel = sc;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return partialSegment;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const withSegmentCookie = (
|
|
111
|
+
segment: WrappedSegment,
|
|
112
|
+
headers?: Headers,
|
|
113
|
+
): Headers => {
|
|
114
|
+
const h = new Headers(headers);
|
|
115
|
+
if (!segment) return h;
|
|
116
|
+
|
|
117
|
+
h.set("cookie", `${SEGMENT_COOKIE_NAME}=${segment.token}`);
|
|
118
|
+
return h;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function getCookieValue(cookieHeader: string, name: string): string | null {
|
|
122
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
123
|
+
return match?.[1] ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a complete segment from request cookies.
|
|
128
|
+
* Reads both vtex_segment and VTEXSC cookies.
|
|
129
|
+
* VTEXSC contains the sales channel and overrides the segment channel.
|
|
130
|
+
*/
|
|
131
|
+
export const buildSegmentFromCookies = (
|
|
132
|
+
cookieHeader: string,
|
|
133
|
+
): Partial<Segment> => {
|
|
134
|
+
const segmentCookie = getCookieValue(cookieHeader, SEGMENT_COOKIE_NAME);
|
|
135
|
+
const vtexsc = getCookieValue(cookieHeader, SALES_CHANNEL_COOKIE);
|
|
136
|
+
|
|
137
|
+
const base = segmentCookie ? parseSegment(segmentCookie) : null;
|
|
138
|
+
const segment: Partial<Segment> = { ...DEFAULT_SEGMENT, ...base };
|
|
139
|
+
|
|
140
|
+
if (vtexsc) {
|
|
141
|
+
segment.channel = vtexsc;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return segment;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if the current segment represents an anonymous user
|
|
149
|
+
* (no campaigns, no UTMs, no regionId, no custom priceTables).
|
|
150
|
+
*/
|
|
151
|
+
export const isAnonymous = (segment: Partial<Segment>): boolean => {
|
|
152
|
+
return (
|
|
153
|
+
!segment.campaigns &&
|
|
154
|
+
!segment.utm_campaign &&
|
|
155
|
+
!segment.utm_source &&
|
|
156
|
+
!segment.utm_medium &&
|
|
157
|
+
!segment.utmi_campaign &&
|
|
158
|
+
!segment.utmi_page &&
|
|
159
|
+
!segment.utmi_part &&
|
|
160
|
+
!segment.regionId &&
|
|
161
|
+
!segment.priceTables
|
|
162
|
+
);
|
|
163
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Product } from "../../commerce/types/commerce";
|
|
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 =
|
|
8
|
+
product.isVariantOf?.productGroupID ?? product.inProductGroupWithID;
|
|
9
|
+
|
|
10
|
+
if (!id) {
|
|
11
|
+
return product;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const rawSimilars = await vtexFetch<LegacyProduct[]>(
|
|
16
|
+
`/api/catalog_system/pub/products/crossselling/similars/${id}`,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
if (!rawSimilars?.length) return product;
|
|
20
|
+
|
|
21
|
+
const config = getVtexConfig();
|
|
22
|
+
const baseUrl = config.publicUrl
|
|
23
|
+
? `https://${config.publicUrl}`
|
|
24
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
25
|
+
|
|
26
|
+
const similars = rawSimilars.map((p) => {
|
|
27
|
+
const sku = pickSku(p);
|
|
28
|
+
return toProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
...product,
|
|
33
|
+
isSimilarTo: similars,
|
|
34
|
+
};
|
|
35
|
+
} catch {
|
|
36
|
+
return product;
|
|
37
|
+
}
|
|
38
|
+
};
|