@decocms/apps 0.29.0 → 1.1.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/commerce/sdk/analytics.ts +161 -20
- package/package.json +2 -1
- package/vtex/actions/address.ts +30 -0
- package/vtex/actions/profile.ts +69 -0
- package/vtex/commerceLoaders.ts +285 -0
- package/vtex/loaders/autocomplete.ts +58 -0
- package/vtex/utils/accountLoaders.ts +202 -0
- package/vtex/utils/authHelpers.ts +97 -0
- package/vtex/utils/cookies.ts +62 -1
- package/vtex/utils/index.ts +2 -0
- package/vtex/utils/intelligentSearch.ts +21 -0
- package/vtex/utils/proxy.ts +247 -0
|
@@ -1,24 +1,165 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Analytics mappers — convert schema.org Product to GA4 AnalyticsItem.
|
|
3
|
+
*
|
|
4
|
+
* These are the generic, platform-independent mappers. Sites can wrap them
|
|
5
|
+
* to add custom fields (sellerP, etc.) via the `extend` option.
|
|
6
|
+
*/
|
|
7
|
+
import type { BreadcrumbList, Product } from "../types";
|
|
8
|
+
|
|
9
|
+
export interface AnalyticsItem {
|
|
10
|
+
item_id?: string;
|
|
11
|
+
item_name?: string;
|
|
12
|
+
affiliation?: string;
|
|
13
|
+
coupon?: string;
|
|
14
|
+
discount?: number;
|
|
15
|
+
index?: number;
|
|
16
|
+
item_group_id?: string;
|
|
17
|
+
item_url?: string;
|
|
18
|
+
item_brand?: string;
|
|
19
|
+
item_category?: string;
|
|
20
|
+
item_category2?: string;
|
|
21
|
+
item_category3?: string;
|
|
22
|
+
item_category4?: string;
|
|
23
|
+
item_category5?: string;
|
|
24
|
+
item_list_id?: string;
|
|
25
|
+
item_list_name?: string;
|
|
26
|
+
item_variant?: string;
|
|
27
|
+
location_id?: string;
|
|
28
|
+
price?: number;
|
|
29
|
+
quantity: number;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mapCategoriesToAnalyticsCategories(
|
|
34
|
+
categories: string[],
|
|
35
|
+
): Record<string, string> {
|
|
36
|
+
return categories.slice(0, 5).reduce(
|
|
37
|
+
(result, category, index) => {
|
|
38
|
+
result[`item_category${index === 0 ? "" : index + 1}`] = category;
|
|
39
|
+
return result;
|
|
40
|
+
},
|
|
41
|
+
{} as Record<string, string>,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function mapProductCategoryToAnalyticsCategories(category: string): Record<string, string> {
|
|
46
|
+
return category.split(">").reduce(
|
|
47
|
+
(result, cat, index) => {
|
|
48
|
+
result[`item_category${index === 0 ? "" : index}`] = cat.trim();
|
|
49
|
+
return result;
|
|
50
|
+
},
|
|
51
|
+
{} as Record<string, string>,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MapProductToAnalyticsItemOptions {
|
|
56
|
+
product: Product;
|
|
57
|
+
breadcrumbList?: BreadcrumbList;
|
|
58
|
+
price?: number;
|
|
59
|
+
lowPrice?: number;
|
|
60
|
+
listPrice?: number;
|
|
61
|
+
index?: number;
|
|
62
|
+
quantity?: number;
|
|
63
|
+
coupon?: string;
|
|
64
|
+
/** Extend the result with custom fields (e.g., sellerP, sellerName) */
|
|
65
|
+
extend?: (product: Product, base: AnalyticsItem) => Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function mapProductToAnalyticsItem(opts: MapProductToAnalyticsItemOptions): AnalyticsItem {
|
|
69
|
+
const {
|
|
70
|
+
product,
|
|
71
|
+
breadcrumbList,
|
|
72
|
+
price,
|
|
73
|
+
lowPrice,
|
|
74
|
+
listPrice,
|
|
75
|
+
index = 0,
|
|
76
|
+
quantity = 1,
|
|
77
|
+
coupon = "",
|
|
78
|
+
extend,
|
|
79
|
+
} = opts;
|
|
80
|
+
|
|
81
|
+
const { name, productID, inProductGroupWithID, isVariantOf, url, sku } = product;
|
|
82
|
+
|
|
83
|
+
const categories = breadcrumbList?.itemListElement
|
|
84
|
+
? mapCategoriesToAnalyticsCategories(
|
|
85
|
+
breadcrumbList.itemListElement
|
|
86
|
+
.map(({ name: n }) => n ?? "")
|
|
87
|
+
.filter(Boolean),
|
|
88
|
+
)
|
|
89
|
+
: mapProductCategoryToAnalyticsCategories(product.category ?? "");
|
|
90
|
+
|
|
91
|
+
const base: AnalyticsItem = {
|
|
92
|
+
item_id: productID,
|
|
93
|
+
item_group_id: inProductGroupWithID,
|
|
94
|
+
quantity,
|
|
95
|
+
coupon,
|
|
96
|
+
price: lowPrice,
|
|
97
|
+
index,
|
|
98
|
+
item_variant: sku,
|
|
99
|
+
discount: Number((price && listPrice ? listPrice - price : 0).toFixed(2)),
|
|
100
|
+
item_name: isVariantOf?.name ?? name ?? "",
|
|
101
|
+
item_brand: product.brand?.name ?? "",
|
|
102
|
+
item_url: url,
|
|
103
|
+
...categories,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (extend) {
|
|
107
|
+
return { ...base, ...extend(product, base) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return base;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface MapProductToAnalyticsItemListOptions {
|
|
114
|
+
product: Product;
|
|
115
|
+
breadcrumbList?: BreadcrumbList;
|
|
116
|
+
price?: number;
|
|
117
|
+
listPrice?: number;
|
|
118
|
+
index?: number;
|
|
119
|
+
quantity?: number;
|
|
120
|
+
coupon?: string;
|
|
10
121
|
}
|
|
11
122
|
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
123
|
+
export function mapProductToAnalyticsItemList(opts: MapProductToAnalyticsItemListOptions): AnalyticsItem {
|
|
124
|
+
const {
|
|
125
|
+
product,
|
|
126
|
+
breadcrumbList,
|
|
127
|
+
price,
|
|
128
|
+
listPrice,
|
|
129
|
+
index = 0,
|
|
130
|
+
quantity = 1,
|
|
131
|
+
coupon = "",
|
|
132
|
+
} = opts;
|
|
133
|
+
|
|
134
|
+
const { name, productID, inProductGroupWithID, isVariantOf, url } = product;
|
|
135
|
+
|
|
136
|
+
const categories = breadcrumbList?.itemListElement
|
|
137
|
+
? mapCategoriesToAnalyticsCategories(
|
|
138
|
+
breadcrumbList.itemListElement
|
|
139
|
+
.map(({ name: n }) => n ?? "")
|
|
140
|
+
.filter(Boolean),
|
|
141
|
+
)
|
|
142
|
+
: mapProductCategoryToAnalyticsCategories(product.category ?? "");
|
|
17
143
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
144
|
+
const finalPrice = typeof price === "number" ? price : 0;
|
|
145
|
+
const discount =
|
|
146
|
+
typeof listPrice === "number" && typeof price === "number"
|
|
147
|
+
? Math.max(0, listPrice - price)
|
|
148
|
+
: 0;
|
|
22
149
|
|
|
23
|
-
|
|
24
|
-
|
|
150
|
+
const itemId = inProductGroupWithID ?? isVariantOf?.productGroupID ?? productID;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
item_id: itemId,
|
|
154
|
+
item_group_id: inProductGroupWithID,
|
|
155
|
+
quantity,
|
|
156
|
+
coupon,
|
|
157
|
+
price: finalPrice,
|
|
158
|
+
index,
|
|
159
|
+
discount: Number(discount.toFixed(2)),
|
|
160
|
+
item_name: isVariantOf?.name ?? name ?? "",
|
|
161
|
+
item_brand: product.brand?.name ?? "",
|
|
162
|
+
item_url: url,
|
|
163
|
+
...categories,
|
|
164
|
+
};
|
|
165
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
|
|
6
6
|
"exports": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"./shopify/actions/user/*": "./shopify/actions/user/*.ts",
|
|
23
23
|
"./shopify/utils/*": "./shopify/utils/*.ts",
|
|
24
24
|
"./vtex": "./vtex/index.ts",
|
|
25
|
+
"./vtex/commerceLoaders": "./vtex/commerceLoaders.ts",
|
|
25
26
|
"./vtex/mod": "./vtex/mod.ts",
|
|
26
27
|
"./vtex/client": "./vtex/client.ts",
|
|
27
28
|
"./vtex/types": "./vtex/types.ts",
|
package/vtex/actions/address.ts
CHANGED
|
@@ -209,3 +209,33 @@ export async function updateAddress(
|
|
|
209
209
|
);
|
|
210
210
|
return result;
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Request-aware wrappers (for COMMERCE_LOADERS / invoke proxy)
|
|
215
|
+
// Handle cookie extraction, postalCode sanitization, and field defaults.
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
import { getVtexCookies, ensureUnsuffixedAuthCookie } from "../utils/cookies";
|
|
219
|
+
|
|
220
|
+
function sanitizeAddressInput(props: Record<string, any>): Record<string, any> {
|
|
221
|
+
if (props.postalCode) props.postalCode = props.postalCode.replace(/\D/g, "");
|
|
222
|
+
if (!props.addressName) props.addressName = props.receiverName || `Address ${Date.now()}`;
|
|
223
|
+
if (!props.addressType) props.addressType = "residential";
|
|
224
|
+
return props;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function createAddressFromRequest(props: Record<string, any>, request: Request): Promise<SavedAddress> {
|
|
228
|
+
const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
|
|
229
|
+
return createAddress(sanitizeAddressInput(props) as AddressInput, cookie);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function updateAddressFromRequest(props: Record<string, any>, request: Request): Promise<UpdateAddressResult> {
|
|
233
|
+
const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
|
|
234
|
+
const { addressId, ...fields } = props;
|
|
235
|
+
return updateAddress(addressId, fields, cookie);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function deleteAddressFromRequest(props: Record<string, any>, request: Request): Promise<DeleteAddressResult> {
|
|
239
|
+
const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
|
|
240
|
+
return deleteAddress(props.addressId, cookie);
|
|
241
|
+
}
|
package/vtex/actions/profile.ts
CHANGED
|
@@ -117,3 +117,72 @@ export async function updateProfile(
|
|
|
117
117
|
);
|
|
118
118
|
return profile;
|
|
119
119
|
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Request-aware wrappers (for COMMERCE_LOADERS / invoke proxy)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
import { getVtexCookies } from "../utils/cookies";
|
|
126
|
+
import { updateNewsletterOptIn } from "./newsletter";
|
|
127
|
+
import { deletePaymentToken } from "./misc";
|
|
128
|
+
import { getCurrentProfile } from "../loaders/profile";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Normalize birthDate strings to ISO 8601.
|
|
132
|
+
* Handles dd/mm/yyyy (Brazilian format), yyyy-mm-dd, and full ISO.
|
|
133
|
+
*/
|
|
134
|
+
function normalizeBirthDate(profile: Record<string, any>): void {
|
|
135
|
+
if (!profile.birthDate || typeof profile.birthDate !== "string") return;
|
|
136
|
+
const ddmmyyyy = profile.birthDate.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
|
137
|
+
if (ddmmyyyy) {
|
|
138
|
+
profile.birthDate = `${ddmmyyyy[3]}-${ddmmyyyy[2]}-${ddmmyyyy[1]}T00:00:00.000Z`;
|
|
139
|
+
} else if (!profile.birthDate.includes("T")) {
|
|
140
|
+
const isoMatch = profile.birthDate.match(/(\d{4})-(\d{2})-(\d{2})/);
|
|
141
|
+
if (isoMatch) {
|
|
142
|
+
profile.birthDate = `${isoMatch[0]}T00:00:00.000Z`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Update user profile via VTEX IO GraphQL. Handles cookie extraction,
|
|
149
|
+
* birthDate normalization, and undefined-key cleanup.
|
|
150
|
+
*/
|
|
151
|
+
export async function updateProfileFromRequest(props: Record<string, any>, request: Request): Promise<any> {
|
|
152
|
+
const { account } = getVtexConfig();
|
|
153
|
+
const cookie = getVtexCookies(request);
|
|
154
|
+
const profile = { ...props };
|
|
155
|
+
normalizeBirthDate(profile);
|
|
156
|
+
for (const key of Object.keys(profile)) {
|
|
157
|
+
if (profile[key] === undefined) delete profile[key];
|
|
158
|
+
}
|
|
159
|
+
const QUERY = `mutation UpdateProfile($profile: ProfileInput!) {
|
|
160
|
+
updateProfile(fields: $profile) @context(provider: "vtex.store-graphql@2.x") {
|
|
161
|
+
cacheId firstName lastName email document gender homePhone
|
|
162
|
+
businessPhone birthDate isCorporate corporateName
|
|
163
|
+
corporateDocument tradeName stateRegistration
|
|
164
|
+
}
|
|
165
|
+
}`;
|
|
166
|
+
const res = await fetch(`https://${account}.myvtex.com/_v/private/graphql/v1`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
body: JSON.stringify({ query: QUERY, variables: { profile } }),
|
|
169
|
+
headers: { "Content-Type": "application/json", cookie },
|
|
170
|
+
});
|
|
171
|
+
return res.json();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function newsletterProfileFromRequest(props: Record<string, any>, request: Request): Promise<any> {
|
|
175
|
+
const cookie = request.headers.get("cookie") ?? "";
|
|
176
|
+
return updateNewsletterOptIn(props.isNewsletterOptIn, props.email, cookie);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function deletePaymentFromRequest(props: Record<string, any>, request: Request): Promise<any> {
|
|
180
|
+
const cookie = getVtexCookies(request);
|
|
181
|
+
return deletePaymentToken(props.id, cookie);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function getPasswordLastUpdate(_props: Record<string, any>, request: Request): Promise<string | null> {
|
|
185
|
+
const cookie = getVtexCookies(request);
|
|
186
|
+
const profile = await getCurrentProfile(cookie);
|
|
187
|
+
return profile?.passwordLastUpdate ?? null;
|
|
188
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard VTEX commerce loader map factory for CMS block resolution.
|
|
3
|
+
*
|
|
4
|
+
* Wraps all VTEX inline loaders with createCachedLoader, applies universal
|
|
5
|
+
* workarounds (slug fallback, IS sort sanitization, map=productClusterIds),
|
|
6
|
+
* and registers both `.ts` and `.ts`-less aliases.
|
|
7
|
+
*
|
|
8
|
+
* Sites call `createVtexCommerceLoaders()` instead of manually wiring ~30
|
|
9
|
+
* individual loader entries. Site-specific loaders are merged via `extra`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createCachedLoader } from "@decocms/start/sdk/cachedLoader";
|
|
13
|
+
import type { CacheProfileName } from "@decocms/start/sdk/cacheHeaders";
|
|
14
|
+
import vtexProductList from "./inline-loaders/productList";
|
|
15
|
+
import vtexProductListShelf from "./inline-loaders/productListShelf";
|
|
16
|
+
import vtexProductDetailsPage from "./inline-loaders/productDetailsPage";
|
|
17
|
+
import vtexProductListingPage from "./inline-loaders/productListingPage";
|
|
18
|
+
import vtexSuggestions from "./inline-loaders/suggestions";
|
|
19
|
+
import vtexRelatedProducts from "./inline-loaders/relatedProducts";
|
|
20
|
+
import vtexWorkflowProducts from "./inline-loaders/workflowProducts";
|
|
21
|
+
import { getCategoryTree } from "./loaders/catalog";
|
|
22
|
+
import { VALID_IS_SORTS } from "./utils/intelligentSearch";
|
|
23
|
+
|
|
24
|
+
export type CommerceLoaderFn = (props: any) => Promise<any>;
|
|
25
|
+
|
|
26
|
+
export interface VtexCommerceLoadersOptions {
|
|
27
|
+
/** Override cache profiles per loader type. */
|
|
28
|
+
cacheProfiles?: {
|
|
29
|
+
listing?: CacheProfileName;
|
|
30
|
+
product?: CacheProfileName;
|
|
31
|
+
search?: CacheProfileName;
|
|
32
|
+
static?: CacheProfileName;
|
|
33
|
+
};
|
|
34
|
+
/** Additional loaders to merge into the map (site-specific). */
|
|
35
|
+
extra?: Record<string, CommerceLoaderFn>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bridge __pagePath → slug when CMS doesn't set slug explicitly.
|
|
40
|
+
* VTEX PDP pages receive __pagePath (e.g. "/produto-slug/p") but the
|
|
41
|
+
* inline loader only reads the `slug` field.
|
|
42
|
+
*/
|
|
43
|
+
function pdpWithSlugFallback(props: any): Promise<any> {
|
|
44
|
+
if ((!props.slug || props.slug.length === 0) && props.__pagePath) {
|
|
45
|
+
props = { ...props, slug: props.__pagePath };
|
|
46
|
+
}
|
|
47
|
+
return vtexProductDetailsPage(props);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract collection name from PLP product data.
|
|
52
|
+
* Products carry cluster info in additionalProperty with name="cluster".
|
|
53
|
+
*/
|
|
54
|
+
function extractCollectionName(
|
|
55
|
+
result: any,
|
|
56
|
+
collectionId: string,
|
|
57
|
+
): string | null {
|
|
58
|
+
if (!result?.products?.length) return null;
|
|
59
|
+
for (const product of result.products) {
|
|
60
|
+
const props =
|
|
61
|
+
product.additionalProperty ||
|
|
62
|
+
product.isVariantOf?.additionalProperty ||
|
|
63
|
+
[];
|
|
64
|
+
for (const prop of props) {
|
|
65
|
+
if (prop.name === "cluster" && prop.propertyID === collectionId) {
|
|
66
|
+
return prop.value || null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns the standard VTEX commerce loader map for CMS resolution.
|
|
75
|
+
*
|
|
76
|
+
* Includes all Intelligent Search, legacy, category tree, and navbar loaders
|
|
77
|
+
* with SWR caching. Also registers `.ts`-less aliases for invoke compatibility.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* import { createVtexCommerceLoaders } from "@decocms/apps/vtex/commerceLoaders";
|
|
82
|
+
*
|
|
83
|
+
* const COMMERCE_LOADERS = {
|
|
84
|
+
* ...createVtexCommerceLoaders(),
|
|
85
|
+
* // site-specific only:
|
|
86
|
+
* "site/loaders/myCustomLoader": async (props) => { ... },
|
|
87
|
+
* };
|
|
88
|
+
* registerCommerceLoaders(COMMERCE_LOADERS);
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function createVtexCommerceLoaders(
|
|
92
|
+
options?: VtexCommerceLoadersOptions,
|
|
93
|
+
): Record<string, CommerceLoaderFn> {
|
|
94
|
+
const profiles = {
|
|
95
|
+
listing: options?.cacheProfiles?.listing ?? "listing",
|
|
96
|
+
product: options?.cacheProfiles?.product ?? "product",
|
|
97
|
+
search: options?.cacheProfiles?.search ?? "search",
|
|
98
|
+
static: options?.cacheProfiles?.static ?? "static",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const cachedProductList = createCachedLoader(
|
|
102
|
+
"vtex/productList",
|
|
103
|
+
vtexProductList,
|
|
104
|
+
profiles.listing,
|
|
105
|
+
);
|
|
106
|
+
const cachedProductListShelf = createCachedLoader(
|
|
107
|
+
"vtex/productListShelf",
|
|
108
|
+
vtexProductListShelf,
|
|
109
|
+
profiles.listing,
|
|
110
|
+
);
|
|
111
|
+
const cachedPDP = createCachedLoader(
|
|
112
|
+
"vtex/productDetailsPage",
|
|
113
|
+
pdpWithSlugFallback,
|
|
114
|
+
profiles.product,
|
|
115
|
+
);
|
|
116
|
+
const _cachedPLP = createCachedLoader(
|
|
117
|
+
"vtex/productListingPage",
|
|
118
|
+
vtexProductListingPage,
|
|
119
|
+
profiles.listing,
|
|
120
|
+
);
|
|
121
|
+
const cachedSuggestions = createCachedLoader(
|
|
122
|
+
"vtex/suggestions",
|
|
123
|
+
vtexSuggestions,
|
|
124
|
+
profiles.search,
|
|
125
|
+
);
|
|
126
|
+
const cachedRelatedProducts = createCachedLoader(
|
|
127
|
+
"vtex/relatedProducts",
|
|
128
|
+
vtexRelatedProducts,
|
|
129
|
+
profiles.product,
|
|
130
|
+
);
|
|
131
|
+
const cachedWorkflowProducts = createCachedLoader(
|
|
132
|
+
"vtex/workflowProducts",
|
|
133
|
+
vtexWorkflowProducts,
|
|
134
|
+
profiles.listing,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* PLP wrapper: handles `map=productClusterIds` legacy URLs and sanitizes
|
|
139
|
+
* IS sort parameters that would cause VTEX API 400 errors.
|
|
140
|
+
*/
|
|
141
|
+
const cachedPLP: CommerceLoaderFn = async (props) => {
|
|
142
|
+
if (props.__pageUrl && !props.selectedFacets?.length) {
|
|
143
|
+
try {
|
|
144
|
+
const pageUrl = new URL(props.__pageUrl, "https://localhost");
|
|
145
|
+
const mapParam = pageUrl.searchParams.get("map");
|
|
146
|
+
if (mapParam && props.__pagePath) {
|
|
147
|
+
const segments = props.__pagePath.split("/").filter(Boolean);
|
|
148
|
+
const mapValues = mapParam.split(",");
|
|
149
|
+
const facets: Array<{ key: string; value: string }> = [];
|
|
150
|
+
for (
|
|
151
|
+
let i = 0;
|
|
152
|
+
i < Math.min(segments.length, mapValues.length);
|
|
153
|
+
i++
|
|
154
|
+
) {
|
|
155
|
+
const key = mapValues[i].trim();
|
|
156
|
+
const value = decodeURIComponent(segments[i]);
|
|
157
|
+
if (key && value) facets.push({ key, value });
|
|
158
|
+
}
|
|
159
|
+
if (facets.length) {
|
|
160
|
+
const rawSort = pageUrl.searchParams.get("sort") ?? "";
|
|
161
|
+
const cleanSort = VALID_IS_SORTS.has(rawSort) ? rawSort : "";
|
|
162
|
+
|
|
163
|
+
if (rawSort !== cleanSort) {
|
|
164
|
+
if (cleanSort) {
|
|
165
|
+
pageUrl.searchParams.set("sort", cleanSort);
|
|
166
|
+
} else {
|
|
167
|
+
pageUrl.searchParams.delete("sort");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const result = await _cachedPLP({
|
|
172
|
+
...props,
|
|
173
|
+
selectedFacets: facets,
|
|
174
|
+
sort: cleanSort || undefined,
|
|
175
|
+
__pageUrl: pageUrl.toString(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const clusterFacet = facets.find(
|
|
179
|
+
(f) => f.key === "productClusterIds",
|
|
180
|
+
);
|
|
181
|
+
if (result && clusterFacet) {
|
|
182
|
+
const collectionName = extractCollectionName(
|
|
183
|
+
result,
|
|
184
|
+
clusterFacet.value,
|
|
185
|
+
);
|
|
186
|
+
if (collectionName) {
|
|
187
|
+
result.breadcrumb = {
|
|
188
|
+
"@type": "BreadcrumbList",
|
|
189
|
+
itemListElement: [
|
|
190
|
+
{
|
|
191
|
+
"@type": "ListItem",
|
|
192
|
+
name: collectionName,
|
|
193
|
+
item: props.__pagePath || "/",
|
|
194
|
+
position: 1,
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
numberOfItems: 1,
|
|
198
|
+
};
|
|
199
|
+
result.seo = { ...result.seo, title: collectionName };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch (e) {
|
|
206
|
+
console.error("[PLP] Error parsing map param:", e);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return _cachedPLP(props);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Related products wrapper: extracts slug from __pagePath when the CMS
|
|
214
|
+
* requestToParam stub returns null (standard in TanStack sites).
|
|
215
|
+
*/
|
|
216
|
+
const relatedWithSlugFallback: CommerceLoaderFn = (props) => {
|
|
217
|
+
if (!props.slug && props.__pagePath) {
|
|
218
|
+
const path = props.__pagePath.replace(/\/p$/, "").replace(/^\//, "");
|
|
219
|
+
props = { ...props, slug: path };
|
|
220
|
+
}
|
|
221
|
+
return cachedRelatedProducts(props);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const loaders: Record<string, CommerceLoaderFn> = {
|
|
225
|
+
// Intelligent Search loaders
|
|
226
|
+
"vtex/loaders/intelligentSearch/productListingPage.ts": cachedPLP,
|
|
227
|
+
"vtex/loaders/intelligentSearch/productList.ts": cachedProductListShelf,
|
|
228
|
+
"vtex/loaders/intelligentSearch/productDetailsPage.ts": cachedPDP,
|
|
229
|
+
"vtex/loaders/intelligentSearch/suggestions.ts": cachedSuggestions,
|
|
230
|
+
// Legacy loaders (map to same cached functions)
|
|
231
|
+
"vtex/loaders/legacy/productDetailsPage.ts": cachedPDP,
|
|
232
|
+
"vtex/loaders/legacy/productList.ts": cachedProductListShelf,
|
|
233
|
+
"vtex/loaders/legacy/relatedProductsLoader.ts": relatedWithSlugFallback,
|
|
234
|
+
// Workflow
|
|
235
|
+
"vtex/loaders/workflow/products.ts": cachedWorkflowProducts,
|
|
236
|
+
// Top-level aliases (used by some CMS block configs)
|
|
237
|
+
"vtex/loaders/ProductList.ts": cachedProductListShelf,
|
|
238
|
+
"vtex/loaders/ProductDetailsPage.ts": cachedPDP,
|
|
239
|
+
"vtex/loaders/ProductListingPage.ts": cachedPLP,
|
|
240
|
+
// Category tree
|
|
241
|
+
"vtex/loaders/categories/tree": (props: any) =>
|
|
242
|
+
getCategoryTree(props?.categoryLevels ?? 3),
|
|
243
|
+
// Commerce passthrough loaders
|
|
244
|
+
"commerce/loaders/navbar.ts": async (props: any) => props.items ?? [],
|
|
245
|
+
"commerce/loaders/product/extensions/detailsPage.ts": async (
|
|
246
|
+
props: any,
|
|
247
|
+
) => {
|
|
248
|
+
const data = props.data;
|
|
249
|
+
if (data?.product) return data;
|
|
250
|
+
return cachedPDP({ __pagePath: props.__pagePath });
|
|
251
|
+
},
|
|
252
|
+
// requestToParam stub — unresolvable in TanStack, pdpWithSlugFallback bridges it
|
|
253
|
+
"website/functions/requestToParam.ts": async () => null,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Register .ts-less aliases for invoke compatibility
|
|
257
|
+
const withAliases: Record<string, CommerceLoaderFn> = { ...loaders };
|
|
258
|
+
for (const key of Object.keys(loaders)) {
|
|
259
|
+
if (key.endsWith(".ts")) {
|
|
260
|
+
withAliases[key.slice(0, -3)] = loaders[key];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (options?.extra) {
|
|
265
|
+
Object.assign(withAliases, options.extra);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return withAliases;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Exposes the cached PDP loader for site-specific section loaders that need
|
|
273
|
+
* to call it directly (e.g. ProductDescription fallback).
|
|
274
|
+
*
|
|
275
|
+
* Returns a new instance each call — sites should cache the reference.
|
|
276
|
+
*/
|
|
277
|
+
export function createCachedPDPLoader(
|
|
278
|
+
profile: CacheProfileName = "product",
|
|
279
|
+
): CommerceLoaderFn {
|
|
280
|
+
return createCachedLoader(
|
|
281
|
+
"vtex/productDetailsPage",
|
|
282
|
+
pdpWithSlugFallback,
|
|
283
|
+
profile,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX Intelligent Search autocomplete — suggestions + product results.
|
|
3
|
+
*
|
|
4
|
+
* Combines /autocomplete_suggestions/ and /product_search/ in parallel,
|
|
5
|
+
* transforms IS products to schema.org via pickSku + toProduct.
|
|
6
|
+
*/
|
|
7
|
+
import { getVtexConfig, intelligentSearch as vtexIS } from "../client";
|
|
8
|
+
import { pickSku, toProduct as toSchemaProduct } from "../utils/transform";
|
|
9
|
+
|
|
10
|
+
export interface AutocompleteProps {
|
|
11
|
+
query: string;
|
|
12
|
+
count?: number;
|
|
13
|
+
showSponsored?: boolean;
|
|
14
|
+
placement?: string;
|
|
15
|
+
fuzzy?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AutocompleteResult {
|
|
19
|
+
searches: Array<{ term: string; count: number; attributes?: any[] }>;
|
|
20
|
+
products: any[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function autocompleteSearch(props: AutocompleteProps): Promise<AutocompleteResult> {
|
|
24
|
+
const query = props.query || "";
|
|
25
|
+
const count = props.count ?? 4;
|
|
26
|
+
if (!query.trim()) return { searches: [], products: [] };
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const [suggestionsData, productsData] = await Promise.all([
|
|
30
|
+
vtexIS<{
|
|
31
|
+
searches: Array<{ term: string; count: number; attributes?: any[] }>;
|
|
32
|
+
}>("/autocomplete_suggestions/", { query }),
|
|
33
|
+
vtexIS<{ products: any[] }>("/product_search/", {
|
|
34
|
+
query,
|
|
35
|
+
count: String(count),
|
|
36
|
+
showSponsored: props.showSponsored !== false ? "true" : "false",
|
|
37
|
+
placement: props.placement ?? "top-search",
|
|
38
|
+
fuzzy: props.fuzzy ?? "0",
|
|
39
|
+
}),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const config = getVtexConfig();
|
|
43
|
+
const baseUrl = config.publicUrl
|
|
44
|
+
? `https://${config.publicUrl}`
|
|
45
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
searches: suggestionsData.searches ?? [],
|
|
49
|
+
products: (productsData.products ?? []).slice(0, count).map((p: any) => {
|
|
50
|
+
const sku = pickSku(p);
|
|
51
|
+
return toSchemaProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" });
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error("[vtex] autocompleteSearch error:", error);
|
|
56
|
+
return { searches: [], products: [] };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-built section loaders for VTEX account pages.
|
|
3
|
+
*
|
|
4
|
+
* Every VTEX site with account pages repeats the same pattern:
|
|
5
|
+
* 1. Extract VTEX cookies from request
|
|
6
|
+
* 2. Call the VTEX user/profile/address/payment API
|
|
7
|
+
* 3. Return enriched props with { device, logged, ...data }
|
|
8
|
+
* 4. Catch errors gracefully (return logged: false)
|
|
9
|
+
*
|
|
10
|
+
* These factories encapsulate that boilerplate.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { vtexAccountLoaders } from "@decocms/apps/vtex/utils/accountLoaders";
|
|
15
|
+
*
|
|
16
|
+
* registerSectionLoaders({
|
|
17
|
+
* "site/sections/Account/PersonalData.tsx": vtexAccountLoaders.personalData(),
|
|
18
|
+
* "site/sections/Account/MyOrders.tsx": vtexAccountLoaders.orders(),
|
|
19
|
+
* "site/sections/Account/Cards.tsx": vtexAccountLoaders.cards(),
|
|
20
|
+
* "site/sections/Account/Addresses.tsx": vtexAccountLoaders.addresses(),
|
|
21
|
+
* "site/sections/Account/Auth.tsx": vtexAccountLoaders.authentication(),
|
|
22
|
+
* "site/sections/Account/Other.tsx": vtexAccountLoaders.loggedIn(),
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
import { getVtexCookies } from "./cookies";
|
|
27
|
+
import { getUser } from "../loaders/user";
|
|
28
|
+
import { getCurrentProfile, type Profile } from "../loaders/profile";
|
|
29
|
+
import { getUserAddresses, type VtexAddress } from "../loaders/address";
|
|
30
|
+
import { getUserPayments, type Payment } from "../loaders/payment";
|
|
31
|
+
import { detectDevice } from "@decocms/start/sdk/useDevice";
|
|
32
|
+
|
|
33
|
+
type Device = "mobile" | "tablet" | "desktop";
|
|
34
|
+
|
|
35
|
+
type SectionLoaderFn = (
|
|
36
|
+
props: Record<string, unknown>,
|
|
37
|
+
req: Request,
|
|
38
|
+
) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
function getDevice(req: Request): Device {
|
|
41
|
+
return detectDevice(req.headers.get("user-agent") ?? "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// personalData — fetches full VTEX profile for personal data sections
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface PersonalDataOptions {
|
|
49
|
+
/** Extra custom profile fields to request beyond the standard set. */
|
|
50
|
+
extraProfileFields?: string[];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Transform the raw VTEX Profile into the shape your component expects.
|
|
54
|
+
* When omitted, the raw Profile object is returned as `profile`.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* vtexAccountLoaders.personalData({
|
|
59
|
+
* mapProfile: (p) => ({
|
|
60
|
+
* "@id": p.userId ?? p.id,
|
|
61
|
+
* email: p.email,
|
|
62
|
+
* givenName: p.firstName ?? null,
|
|
63
|
+
* familyName: p.lastName ?? null,
|
|
64
|
+
* taxID: p.document,
|
|
65
|
+
* }),
|
|
66
|
+
* })
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
mapProfile?: (profile: Profile) => Record<string, unknown>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function personalData(options?: PersonalDataOptions): SectionLoaderFn {
|
|
73
|
+
const { extraProfileFields, mapProfile } = options ?? {};
|
|
74
|
+
return async (props, req) => {
|
|
75
|
+
const cookie = getVtexCookies(req);
|
|
76
|
+
try {
|
|
77
|
+
const profile = await getCurrentProfile(cookie, extraProfileFields);
|
|
78
|
+
const data = mapProfile ? mapProfile(profile) : profile;
|
|
79
|
+
return {
|
|
80
|
+
...props,
|
|
81
|
+
device: getDevice(req),
|
|
82
|
+
logged: !!profile,
|
|
83
|
+
loading: false,
|
|
84
|
+
userData: data,
|
|
85
|
+
};
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error("[accountLoaders.personalData]", error);
|
|
88
|
+
return { ...props, device: getDevice(req), logged: false, loading: false, userData: null };
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// orders — checks login status for order listing sections
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
function orders(): SectionLoaderFn {
|
|
98
|
+
return async (props, req) => {
|
|
99
|
+
const cookie = getVtexCookies(req);
|
|
100
|
+
try {
|
|
101
|
+
const userData = await getUser(cookie);
|
|
102
|
+
return { ...props, device: getDevice(req), logged: !!userData };
|
|
103
|
+
} catch {
|
|
104
|
+
return { ...props, device: getDevice(req), logged: false };
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// cards — fetches saved payment tokens for card management sections
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function cards(): SectionLoaderFn {
|
|
114
|
+
return async (props, req) => {
|
|
115
|
+
const cookie = getVtexCookies(req);
|
|
116
|
+
try {
|
|
117
|
+
const user = await getUser(cookie);
|
|
118
|
+
const logged = !!user;
|
|
119
|
+
let payments: Payment[] = [];
|
|
120
|
+
if (logged) {
|
|
121
|
+
try {
|
|
122
|
+
payments = (await getUserPayments(cookie)) ?? [];
|
|
123
|
+
} catch {
|
|
124
|
+
payments = [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { ...props, logged, payments };
|
|
128
|
+
} catch {
|
|
129
|
+
return { ...props, logged: false, payments: [] };
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// addresses — fetches address list for address management sections
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function addresses(): SectionLoaderFn {
|
|
139
|
+
return async (props, req) => {
|
|
140
|
+
const cookie = getVtexCookies(req);
|
|
141
|
+
try {
|
|
142
|
+
const userData = await getUser(cookie);
|
|
143
|
+
const logged = !!userData;
|
|
144
|
+
let userAddressData: VtexAddress[] | null = null;
|
|
145
|
+
if (logged) {
|
|
146
|
+
try {
|
|
147
|
+
userAddressData = await getUserAddresses(cookie);
|
|
148
|
+
} catch {
|
|
149
|
+
userAddressData = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { ...props, device: getDevice(req), logged, userAddressData };
|
|
153
|
+
} catch {
|
|
154
|
+
return { ...props, device: getDevice(req), logged: false, userAddressData: null };
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// authentication — checks login + returns user data for auth pages
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
function authentication(): SectionLoaderFn {
|
|
164
|
+
return async (props, req) => {
|
|
165
|
+
const cookie = getVtexCookies(req);
|
|
166
|
+
try {
|
|
167
|
+
const userData = await getUser(cookie);
|
|
168
|
+
return { ...props, device: getDevice(req), logged: !!userData, userData };
|
|
169
|
+
} catch {
|
|
170
|
+
return { ...props, device: getDevice(req), logged: false, userData: null };
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// loggedIn — generic "is the user logged in?" loader
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
function loggedIn(): SectionLoaderFn {
|
|
180
|
+
return async (props, req) => {
|
|
181
|
+
const cookie = getVtexCookies(req);
|
|
182
|
+
try {
|
|
183
|
+
const userData = await getUser(cookie);
|
|
184
|
+
return { ...props, device: getDevice(req), logged: !!userData };
|
|
185
|
+
} catch {
|
|
186
|
+
return { ...props, device: getDevice(req), logged: false };
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Public API
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
export const vtexAccountLoaders = {
|
|
196
|
+
personalData,
|
|
197
|
+
orders,
|
|
198
|
+
cards,
|
|
199
|
+
addresses,
|
|
200
|
+
authentication,
|
|
201
|
+
loggedIn,
|
|
202
|
+
} as const;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX auth helpers — pure functions for cookie extraction, JWT parsing,
|
|
3
|
+
* Set-Cookie forwarding, and logout.
|
|
4
|
+
*
|
|
5
|
+
* These are consumed by site-level createServerFn wrappers in invoke.ts.
|
|
6
|
+
* createServerFn itself must live in site source (not node_modules) because
|
|
7
|
+
* TanStack Start's Vite plugin only transforms source files.
|
|
8
|
+
*/
|
|
9
|
+
import { getVtexConfig } from "../client";
|
|
10
|
+
|
|
11
|
+
const DOMAIN_RE = /;\s*domain=[^;]*/gi;
|
|
12
|
+
|
|
13
|
+
const VTEX_COOKIE_PREFIXES = [
|
|
14
|
+
"vtex_session=",
|
|
15
|
+
"vtex_segment=",
|
|
16
|
+
"VtexIdclientAutCookie",
|
|
17
|
+
"checkout.vtex.com",
|
|
18
|
+
"CheckoutOrderFormOwnership",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract VTEX-relevant cookies from a raw Cookie header string.
|
|
23
|
+
* Filters out analytics/CF cookies that can cause VTEX 503 errors.
|
|
24
|
+
*/
|
|
25
|
+
export function extractVtexCookiesFromHeader(raw: string): string {
|
|
26
|
+
return raw
|
|
27
|
+
.split(";")
|
|
28
|
+
.map((c) => c.trim())
|
|
29
|
+
.filter((c) => VTEX_COOKIE_PREFIXES.some((prefix) => c.startsWith(prefix)))
|
|
30
|
+
.join("; ");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Strip Domain= from Set-Cookie headers so cookies are associated
|
|
35
|
+
* with the storefront domain instead of the VTEX domain.
|
|
36
|
+
*/
|
|
37
|
+
export function stripCookieDomain(cookies: string[]): string[] {
|
|
38
|
+
return cookies.map((c) => c.replace(DOMAIN_RE, ""));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Standard VTEX cookies to expire on logout. */
|
|
42
|
+
export const VTEX_LOGOUT_COOKIES = [
|
|
43
|
+
"checkout.vtex.com=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax",
|
|
44
|
+
"CheckoutOrderFormOwnership=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax",
|
|
45
|
+
"checkout.vtex.com__orderFormId=; Path=/; Max-Age=0",
|
|
46
|
+
"vtex_session=; Path=/; Max-Age=0",
|
|
47
|
+
"vtex_segment=; Path=/; Max-Age=0",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Perform VTEX logout — calls the VTEX ID logout endpoint and returns
|
|
52
|
+
* the Set-Cookie headers (with domain stripped) to expire auth cookies.
|
|
53
|
+
*/
|
|
54
|
+
export async function performVtexLogout(cookies: string): Promise<{ setCookies: string[] }> {
|
|
55
|
+
const config = getVtexConfig();
|
|
56
|
+
const domain = config.domain ?? "com.br";
|
|
57
|
+
const logoutUrl = `https://${config.account}.vtexcommercestable.${domain}/api/vtexid/pub/logout?scope=${config.account}&returnUrl=/`;
|
|
58
|
+
|
|
59
|
+
const res = await fetch(logoutUrl, {
|
|
60
|
+
method: "GET",
|
|
61
|
+
headers: { cookie: cookies },
|
|
62
|
+
redirect: "manual",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const upstreamCookies = res.headers.getSetCookie?.() ?? [];
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
setCookies: [
|
|
69
|
+
...stripCookieDomain(upstreamCookies),
|
|
70
|
+
...VTEX_LOGOUT_COOKIES,
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse VTEX auth JWT to extract email and userId.
|
|
77
|
+
* Reads the VtexIdclientAutCookie_* cookie from a raw Cookie header.
|
|
78
|
+
*/
|
|
79
|
+
export function parseVtexAuthJwt(rawCookies: string): { email: string; userId: string } | null {
|
|
80
|
+
try {
|
|
81
|
+
const match = rawCookies.match(/VtexIdclientAutCookie_[^=]+=([^;]+)/);
|
|
82
|
+
if (!match) return null;
|
|
83
|
+
const token = match[1];
|
|
84
|
+
const parts = token.split(".");
|
|
85
|
+
if (parts.length < 2) return null;
|
|
86
|
+
const payload = JSON.parse(
|
|
87
|
+
Buffer.from(
|
|
88
|
+
parts[1].replace(/-/g, "+").replace(/_/g, "/"),
|
|
89
|
+
"base64",
|
|
90
|
+
).toString("utf-8"),
|
|
91
|
+
);
|
|
92
|
+
if (!payload.sub) return null;
|
|
93
|
+
return { email: payload.sub, userId: payload.userId ?? "" };
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/vtex/utils/cookies.ts
CHANGED
|
@@ -20,13 +20,22 @@ function parseSingleSetCookie(raw: string): Cookie | null {
|
|
|
20
20
|
value: nameValue.slice(eqIdx + 1),
|
|
21
21
|
};
|
|
22
22
|
for (const attr of attrs) {
|
|
23
|
-
const
|
|
23
|
+
const eqi = attr.indexOf("=");
|
|
24
|
+
const k = (eqi >= 0 ? attr.slice(0, eqi) : attr).trim();
|
|
25
|
+
const v = eqi >= 0 ? attr.slice(eqi + 1).trim() : "";
|
|
24
26
|
const lower = k.toLowerCase();
|
|
25
27
|
if (lower === "domain") cookie.domain = v;
|
|
26
28
|
else if (lower === "path") cookie.path = v;
|
|
27
29
|
else if (lower === "secure") cookie.secure = true;
|
|
28
30
|
else if (lower === "httponly") cookie.httpOnly = true;
|
|
29
31
|
else if (lower === "samesite") cookie.sameSite = v as Cookie["sameSite"];
|
|
32
|
+
else if (lower === "max-age") {
|
|
33
|
+
const n = Number(v);
|
|
34
|
+
if (!Number.isNaN(n)) cookie.maxAge = n;
|
|
35
|
+
} else if (lower === "expires") {
|
|
36
|
+
const d = new Date(v);
|
|
37
|
+
if (!Number.isNaN(d.getTime())) cookie.expires = d;
|
|
38
|
+
}
|
|
30
39
|
}
|
|
31
40
|
return cookie;
|
|
32
41
|
}
|
|
@@ -111,3 +120,55 @@ export const proxySetCookie = (from: Headers, to: Headers, toDomain?: URL | stri
|
|
|
111
120
|
|
|
112
121
|
export const CHECKOUT_DATA_ACCESS_COOKIE = "CheckoutDataAccess";
|
|
113
122
|
export const VTEX_CHKO_AUTH = "Vtex_CHKO_Auth";
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Cookie name prefixes that are VTEX-relevant.
|
|
126
|
+
* Used by getVtexCookies() to filter request cookies before forwarding
|
|
127
|
+
* to VTEX APIs — avoids undici non-ASCII warnings from other cookies.
|
|
128
|
+
*/
|
|
129
|
+
export const VTEX_COOKIE_PREFIXES = [
|
|
130
|
+
"VtexIdclientAutCookie",
|
|
131
|
+
"checkout.vtex",
|
|
132
|
+
"CheckoutOrderFormOwnership",
|
|
133
|
+
"vtex_is_",
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Filter a request's cookies to only VTEX-relevant ones.
|
|
138
|
+
* Prevents undici non-ASCII header warnings when forwarding cookies to VTEX APIs.
|
|
139
|
+
*/
|
|
140
|
+
export function getVtexCookies(request: Request): string {
|
|
141
|
+
const raw = request.headers.get("cookie") ?? "";
|
|
142
|
+
return raw
|
|
143
|
+
.split(";")
|
|
144
|
+
.map((c) => c.trim())
|
|
145
|
+
.filter((c) => VTEX_COOKIE_PREFIXES.some((p) => c.startsWith(p)))
|
|
146
|
+
.join("; ");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Ensure the unsuffixed VtexIdclientAutCookie is present alongside the
|
|
151
|
+
* account-suffixed variant (e.g. VtexIdclientAutCookie_myaccount).
|
|
152
|
+
*
|
|
153
|
+
* VTEX GraphQL requires both the suffixed AND unsuffixed cookie for
|
|
154
|
+
* authenticated mutations. The browser only stores the suffixed variant,
|
|
155
|
+
* so server-side code must synthesize the unsuffixed one.
|
|
156
|
+
*/
|
|
157
|
+
export function ensureUnsuffixedAuthCookie(cookieStr: string): string {
|
|
158
|
+
if (!cookieStr) return cookieStr;
|
|
159
|
+
const cookies = cookieStr.split(";").map((c) => c.trim());
|
|
160
|
+
let hasUnsuffixed = false;
|
|
161
|
+
let suffixedToken: string | null = null;
|
|
162
|
+
for (const c of cookies) {
|
|
163
|
+
const [name, ...rest] = c.split("=");
|
|
164
|
+
if (name === "VtexIdclientAutCookie") {
|
|
165
|
+
hasUnsuffixed = true;
|
|
166
|
+
} else if (name?.startsWith("VtexIdclientAutCookie_") && !suffixedToken) {
|
|
167
|
+
suffixedToken = rest.join("=");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!hasUnsuffixed && suffixedToken) {
|
|
171
|
+
return `VtexIdclientAutCookie=${suffixedToken}; ${cookieStr}`;
|
|
172
|
+
}
|
|
173
|
+
return cookieStr;
|
|
174
|
+
}
|
package/vtex/utils/index.ts
CHANGED
|
@@ -44,6 +44,27 @@ export const withDefaultParams = ({
|
|
|
44
44
|
|
|
45
45
|
export const isFilterParam = (keyFilter: string): boolean => keyFilter.startsWith("filter.");
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Valid VTEX Intelligent Search sort values.
|
|
49
|
+
* Anything else (e.g. "orders:desc)" with a trailing paren from legacy URLs)
|
|
50
|
+
* causes IS API to return 400.
|
|
51
|
+
*/
|
|
52
|
+
export const VALID_IS_SORTS = new Set([
|
|
53
|
+
"",
|
|
54
|
+
"orders:desc",
|
|
55
|
+
"price:asc",
|
|
56
|
+
"price:desc",
|
|
57
|
+
"name:asc",
|
|
58
|
+
"name:desc",
|
|
59
|
+
"release:desc",
|
|
60
|
+
"discount:desc",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** Sanitize an IS sort parameter — returns empty string for invalid values. */
|
|
64
|
+
export function sanitizeISSort(sort: string): string {
|
|
65
|
+
return VALID_IS_SORTS.has(sort) ? sort : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
const segmentsFromTerm = (term: string) => term.split("/").filter(Boolean);
|
|
48
69
|
|
|
49
70
|
const segmentsFromSearchParams = (url: string) => {
|
package/vtex/utils/proxy.ts
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* Proxies storefront requests for /checkout, /account, /api, /files, /arquivos
|
|
5
5
|
* to the VTEX origin. Essential for checkout and My Account pages to work.
|
|
6
6
|
*
|
|
7
|
+
* Two flavors:
|
|
8
|
+
* - `proxyToVtex()` — simple single-origin proxy (vtexcommercestable)
|
|
9
|
+
* - `createVtexCheckoutProxy()` — production-grade dual-origin proxy with
|
|
10
|
+
* proper cookie attribute preservation, non-ASCII sanitization, and
|
|
11
|
+
* configurable origin routing (checkout UI vs API paths)
|
|
12
|
+
*
|
|
7
13
|
* Designed to be used with TanStack Start API routes or Cloudflare Worker
|
|
8
14
|
* fetch handlers.
|
|
9
15
|
*/
|
|
@@ -194,3 +200,244 @@ export async function proxyToVtex(request: Request, options?: VtexProxyOptions):
|
|
|
194
200
|
headers: responseHeaders,
|
|
195
201
|
});
|
|
196
202
|
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Production-grade checkout proxy factory
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
export interface VtexCheckoutProxyConfig {
|
|
209
|
+
/** VTEX account name (e.g. "casaevideonewio"). */
|
|
210
|
+
account: string;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Store's public checkout domain (e.g. "secure.casaevideo.com.br").
|
|
214
|
+
* Checkout UI, /files/, and /_v/private/graphql are routed here.
|
|
215
|
+
*/
|
|
216
|
+
checkoutOrigin: string;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* VTEX commerce-stable origin for API calls.
|
|
220
|
+
* @default `https://{account}.vtexcommercestable.com.br`
|
|
221
|
+
*/
|
|
222
|
+
apiOrigin?: string;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* myvtex origin — used for redirect rewriting.
|
|
226
|
+
* @default `https://{account}.myvtex.com`
|
|
227
|
+
*/
|
|
228
|
+
myvtexOrigin?: string;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* VTEX TLD — most accounts use `.com.br`, but some use `.com`.
|
|
232
|
+
* @default "com.br"
|
|
233
|
+
*/
|
|
234
|
+
domain?: string;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Extra paths on which to force-expire cookies.
|
|
238
|
+
* Useful for logout: VTEX sends Max-Age=0 for auth cookies, but the
|
|
239
|
+
* checkout orderForm cookie sometimes survives. This appends explicit
|
|
240
|
+
* Set-Cookie: name=; Max-Age=0 entries.
|
|
241
|
+
*/
|
|
242
|
+
expireCookiesOnPaths?: Array<{
|
|
243
|
+
pathPrefix: string;
|
|
244
|
+
cookies: string[];
|
|
245
|
+
}>;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Optional HTML transform for checkout pages.
|
|
249
|
+
* Receives the full HTML string and should return the modified version.
|
|
250
|
+
*/
|
|
251
|
+
htmlTransform?: (html: string) => string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const CF_INTERNAL_HEADERS = new Set([
|
|
255
|
+
"cf-connecting-ip",
|
|
256
|
+
"cf-ipcountry",
|
|
257
|
+
"cf-ray",
|
|
258
|
+
"cf-visitor",
|
|
259
|
+
"cf-ew-via",
|
|
260
|
+
"cdn-loop",
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
const CHECKOUT_SKIP_HEADERS = new Set([
|
|
264
|
+
...HOP_BY_HOP_HEADERS,
|
|
265
|
+
"set-cookie",
|
|
266
|
+
...CF_INTERNAL_HEADERS,
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
const toAscii = (v: string) => v.replace(/[^\x20-\x7E]/g, "");
|
|
270
|
+
|
|
271
|
+
function filterHeadersStrict(headers: Headers): Headers {
|
|
272
|
+
const filtered = new Headers();
|
|
273
|
+
headers.forEach((value, key) => {
|
|
274
|
+
if (CHECKOUT_SKIP_HEADERS.has(key.toLowerCase())) return;
|
|
275
|
+
try {
|
|
276
|
+
filtered.set(key, toAscii(value));
|
|
277
|
+
} catch {
|
|
278
|
+
// skip headers that still fail after sanitization
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
return filtered;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Rewrite Set-Cookie headers: only change the Domain attribute.
|
|
286
|
+
* Unlike `proxySetCookie`, this preserves ALL attributes (Max-Age,
|
|
287
|
+
* Expires, SameSite, etc.) which is critical for logout.
|
|
288
|
+
*/
|
|
289
|
+
function rewriteSetCookieDomain(
|
|
290
|
+
from: Headers,
|
|
291
|
+
to: Headers,
|
|
292
|
+
toHostname: string,
|
|
293
|
+
) {
|
|
294
|
+
const raw: string[] =
|
|
295
|
+
typeof from.getSetCookie === "function"
|
|
296
|
+
? from.getSetCookie()
|
|
297
|
+
: (from.get("set-cookie") ?? "")
|
|
298
|
+
.split(/,(?=[^ ]+=)/)
|
|
299
|
+
.filter(Boolean);
|
|
300
|
+
|
|
301
|
+
for (const cookie of raw) {
|
|
302
|
+
const rewritten = cookie.replace(/Domain=[^;]*/i, `Domain=${toHostname}`);
|
|
303
|
+
to.append("Set-Cookie", rewritten);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Creates a production-grade VTEX checkout proxy handler.
|
|
309
|
+
*
|
|
310
|
+
* Routes checkout UI pages to the store's public domain and API calls
|
|
311
|
+
* to vtexcommercestable. Properly rewrites Set-Cookie domains (preserving
|
|
312
|
+
* Max-Age/Expires), sanitizes non-ASCII headers, filters hop-by-hop and
|
|
313
|
+
* CF-internal headers, and rewrites Location redirects.
|
|
314
|
+
*
|
|
315
|
+
* Returns a function compatible with `createDecoWorkerEntry`'s `proxyHandler`.
|
|
316
|
+
*
|
|
317
|
+
* @example
|
|
318
|
+
* ```ts
|
|
319
|
+
* const vtexProxy = createVtexCheckoutProxy({
|
|
320
|
+
* account: "casaevideonewio",
|
|
321
|
+
* checkoutOrigin: "secure.casaevideo.com.br",
|
|
322
|
+
* expireCookiesOnPaths: [
|
|
323
|
+
* { pathPrefix: "/api/vtexid/pub/logout", cookies: ["checkout.vtex.com"] },
|
|
324
|
+
* ],
|
|
325
|
+
* htmlTransform: (html) =>
|
|
326
|
+
* html.replace("</head>", "<style>.body{min-height:100vh}</style></head>"),
|
|
327
|
+
* });
|
|
328
|
+
*
|
|
329
|
+
* createDecoWorkerEntry(serverEntry, {
|
|
330
|
+
* proxyHandler: async (request, url) => {
|
|
331
|
+
* if (url.pathname === "/login") return null;
|
|
332
|
+
* if (!shouldProxyToVtex(url.pathname)) return null;
|
|
333
|
+
* return vtexProxy(request, url);
|
|
334
|
+
* },
|
|
335
|
+
* });
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
export function createVtexCheckoutProxy(
|
|
339
|
+
config: VtexCheckoutProxyConfig,
|
|
340
|
+
): (request: Request, url: URL) => Promise<Response> {
|
|
341
|
+
const domain = config.domain ?? "com.br";
|
|
342
|
+
const checkoutOrigin = config.checkoutOrigin.startsWith("https://")
|
|
343
|
+
? config.checkoutOrigin
|
|
344
|
+
: `https://${config.checkoutOrigin}`;
|
|
345
|
+
const apiOrigin =
|
|
346
|
+
config.apiOrigin ??
|
|
347
|
+
`https://${config.account}.vtexcommercestable.${domain}`;
|
|
348
|
+
const myvtexOrigin =
|
|
349
|
+
config.myvtexOrigin ?? `https://${config.account}.myvtex.com`;
|
|
350
|
+
|
|
351
|
+
function getOrigin(pathname: string, method: string): string {
|
|
352
|
+
if (
|
|
353
|
+
pathname.startsWith("/checkout") ||
|
|
354
|
+
pathname.startsWith("/account") ||
|
|
355
|
+
pathname.startsWith("/_secure/account") ||
|
|
356
|
+
pathname.startsWith("/files/") ||
|
|
357
|
+
pathname.startsWith("/_v/private/graphql")
|
|
358
|
+
) {
|
|
359
|
+
return checkoutOrigin;
|
|
360
|
+
}
|
|
361
|
+
if (method !== "GET" && method !== "HEAD" && pathname.startsWith("/_v/")) {
|
|
362
|
+
return checkoutOrigin;
|
|
363
|
+
}
|
|
364
|
+
return apiOrigin;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return async (request: Request, url: URL): Promise<Response> => {
|
|
368
|
+
const origin = getOrigin(url.pathname, request.method);
|
|
369
|
+
const originUrl = new URL(`${origin}${url.pathname}${url.search}`);
|
|
370
|
+
const fwd = filterHeadersStrict(new Headers(request.headers));
|
|
371
|
+
|
|
372
|
+
fwd.set("Host", originUrl.hostname);
|
|
373
|
+
fwd.set("X-Forwarded-Host", url.host);
|
|
374
|
+
fwd.set("X-Forwarded-Proto", "https");
|
|
375
|
+
fwd.set("origin", request.headers.get("origin") ?? url.origin);
|
|
376
|
+
|
|
377
|
+
const isCheckoutUI =
|
|
378
|
+
url.pathname.startsWith("/checkout") ||
|
|
379
|
+
url.pathname.startsWith("/account");
|
|
380
|
+
const isLogout = url.pathname.startsWith("/api/vtexid/pub/logout");
|
|
381
|
+
|
|
382
|
+
const init: RequestInit = {
|
|
383
|
+
method: request.method,
|
|
384
|
+
headers: fwd,
|
|
385
|
+
redirect: isCheckoutUI || isLogout ? "manual" : "follow",
|
|
386
|
+
};
|
|
387
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
388
|
+
init.body = request.body;
|
|
389
|
+
// @ts-expect-error -- needed for streaming body in Workers
|
|
390
|
+
init.duplex = "half";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const originRes = await fetch(originUrl.toString(), init);
|
|
394
|
+
const resHeaders = filterHeadersStrict(new Headers(originRes.headers));
|
|
395
|
+
rewriteSetCookieDomain(originRes.headers, resHeaders, url.hostname);
|
|
396
|
+
|
|
397
|
+
// Force-expire cookies on configured paths
|
|
398
|
+
if (config.expireCookiesOnPaths) {
|
|
399
|
+
for (const rule of config.expireCookiesOnPaths) {
|
|
400
|
+
if (url.pathname.startsWith(rule.pathPrefix)) {
|
|
401
|
+
for (const name of rule.cookies) {
|
|
402
|
+
resHeaders.append(
|
|
403
|
+
"Set-Cookie",
|
|
404
|
+
`${name}=; Path=/; Max-Age=0; Domain=${url.hostname}`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Rewrite redirect Location headers from VTEX domains to storefront
|
|
412
|
+
if (originRes.status >= 300 && originRes.status < 400) {
|
|
413
|
+
const loc = originRes.headers.get("location");
|
|
414
|
+
if (loc) {
|
|
415
|
+
resHeaders.set(
|
|
416
|
+
"location",
|
|
417
|
+
loc
|
|
418
|
+
.replace(checkoutOrigin, url.origin)
|
|
419
|
+
.replace(apiOrigin, url.origin)
|
|
420
|
+
.replace(myvtexOrigin, url.origin),
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// HTML transform for checkout pages
|
|
426
|
+
const ct = originRes.headers.get("content-type") ?? "";
|
|
427
|
+
if (config.htmlTransform && ct.includes("text/html")) {
|
|
428
|
+
const html = await originRes.text();
|
|
429
|
+
const patched = config.htmlTransform(html);
|
|
430
|
+
return new Response(patched, {
|
|
431
|
+
status: originRes.status,
|
|
432
|
+
statusText: originRes.statusText,
|
|
433
|
+
headers: resHeaders,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return new Response(originRes.body, {
|
|
438
|
+
status: originRes.status,
|
|
439
|
+
statusText: originRes.statusText,
|
|
440
|
+
headers: resHeaders,
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
}
|