@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.
@@ -1,24 +1,165 @@
1
- import type { AnalyticsEvent } from "../types/commerce";
2
-
3
- declare global {
4
- interface Window {
5
- DECO: { events: { dispatch: (event: any) => void } };
6
- DECO_SITES_STD: {
7
- sendAnalyticsEvent: (args: AnalyticsEvent) => void;
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 const sendEvent = <E extends AnalyticsEvent>(event: E) => {
13
- if (typeof globalThis.window?.DECO?.events?.dispatch === "function") {
14
- globalThis.window.DECO.events.dispatch(event);
15
- return;
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
- if (typeof globalThis.window?.DECO_SITES_STD?.sendAnalyticsEvent === "function") {
19
- globalThis.window.DECO_SITES_STD.sendAnalyticsEvent(event);
20
- return;
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
- console.info("[analytics] No event dispatcher found. Event not sent:", event.name);
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": "0.29.0",
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",
@@ -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
+ }
@@ -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
+ }
@@ -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 [k, v] = attr.split("=").map((s) => s.trim());
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
+ }
@@ -1,3 +1,5 @@
1
+ export { vtexAccountLoaders } from "./accountLoaders";
2
+ export type { PersonalDataOptions } from "./accountLoaders";
1
3
  export * from "./batch";
2
4
  export * from "./cookies";
3
5
  export * from "./enrichment";
@@ -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) => {
@@ -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
+ }