@decocms/apps 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,162 +4,148 @@
4
4
  * These are the generic, platform-independent mappers. Sites can wrap them
5
5
  * to add custom fields (sellerP, etc.) via the `extend` option.
6
6
  */
7
- import type { BreadcrumbList, Product } from "../types";
7
+ import type { BreadcrumbList, Product } from "../types/commerce";
8
8
 
9
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;
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
31
  }
32
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
- );
33
+ export function mapCategoriesToAnalyticsCategories(categories: string[]): Record<string, string> {
34
+ return categories.slice(0, 5).reduce(
35
+ (result, category, index) => {
36
+ result[`item_category${index === 0 ? "" : index + 1}`] = category;
37
+ return result;
38
+ },
39
+ {} as Record<string, string>,
40
+ );
43
41
  }
44
42
 
45
43
  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
- );
44
+ return category.split(">").reduce(
45
+ (result, cat, index) => {
46
+ result[`item_category${index === 0 ? "" : index}`] = cat.trim();
47
+ return result;
48
+ },
49
+ {} as Record<string, string>,
50
+ );
53
51
  }
54
52
 
55
53
  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>;
54
+ product: Product;
55
+ breadcrumbList?: BreadcrumbList;
56
+ price?: number;
57
+ lowPrice?: number;
58
+ listPrice?: number;
59
+ index?: number;
60
+ quantity?: number;
61
+ coupon?: string;
62
+ /** Extend the result with custom fields (e.g., sellerP, sellerName) */
63
+ extend?: (product: Product, base: AnalyticsItem) => Record<string, unknown>;
66
64
  }
67
65
 
68
66
  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;
67
+ const {
68
+ product,
69
+ breadcrumbList,
70
+ price,
71
+ lowPrice,
72
+ listPrice,
73
+ index = 0,
74
+ quantity = 1,
75
+ coupon = "",
76
+ extend,
77
+ } = opts;
78
+
79
+ const { name, productID, inProductGroupWithID, isVariantOf, url, sku } = product;
80
+
81
+ const categories = breadcrumbList?.itemListElement
82
+ ? mapCategoriesToAnalyticsCategories(
83
+ breadcrumbList.itemListElement.map(({ name: n }) => n ?? "").filter(Boolean),
84
+ )
85
+ : mapProductCategoryToAnalyticsCategories(product.category ?? "");
86
+
87
+ const base: AnalyticsItem = {
88
+ item_id: productID,
89
+ item_group_id: inProductGroupWithID,
90
+ quantity,
91
+ coupon,
92
+ price: lowPrice,
93
+ index,
94
+ item_variant: sku,
95
+ discount: Number((price && listPrice ? listPrice - price : 0).toFixed(2)),
96
+ item_name: isVariantOf?.name ?? name ?? "",
97
+ item_brand: product.brand?.name ?? "",
98
+ item_url: url,
99
+ ...categories,
100
+ };
101
+
102
+ if (extend) {
103
+ return { ...base, ...extend(product, base) };
104
+ }
105
+
106
+ return base;
111
107
  }
112
108
 
113
109
  export interface MapProductToAnalyticsItemListOptions {
114
- product: Product;
115
- breadcrumbList?: BreadcrumbList;
116
- price?: number;
117
- listPrice?: number;
118
- index?: number;
119
- quantity?: number;
120
- coupon?: string;
110
+ product: Product;
111
+ breadcrumbList?: BreadcrumbList;
112
+ price?: number;
113
+ listPrice?: number;
114
+ index?: number;
115
+ quantity?: number;
116
+ coupon?: string;
121
117
  }
122
118
 
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 ?? "");
143
-
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;
149
-
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
- };
119
+ export function mapProductToAnalyticsItemList(
120
+ opts: MapProductToAnalyticsItemListOptions,
121
+ ): AnalyticsItem {
122
+ const { product, breadcrumbList, price, listPrice, index = 0, quantity = 1, coupon = "" } = opts;
123
+
124
+ const { name, productID, inProductGroupWithID, isVariantOf, url } = product;
125
+
126
+ const categories = breadcrumbList?.itemListElement
127
+ ? mapCategoriesToAnalyticsCategories(
128
+ breadcrumbList.itemListElement.map(({ name: n }) => n ?? "").filter(Boolean),
129
+ )
130
+ : mapProductCategoryToAnalyticsCategories(product.category ?? "");
131
+
132
+ const finalPrice = typeof price === "number" ? price : 0;
133
+ const discount =
134
+ typeof listPrice === "number" && typeof price === "number" ? Math.max(0, listPrice - price) : 0;
135
+
136
+ const itemId = inProductGroupWithID ?? isVariantOf?.productGroupID ?? productID;
137
+
138
+ return {
139
+ item_id: itemId,
140
+ item_group_id: inProductGroupWithID,
141
+ quantity,
142
+ coupon,
143
+ price: finalPrice,
144
+ index,
145
+ discount: Number(discount.toFixed(2)),
146
+ item_name: isVariantOf?.name ?? name ?? "",
147
+ item_brand: product.brand?.name ?? "",
148
+ item_url: url,
149
+ ...categories,
150
+ };
165
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {
package/shopify/init.ts CHANGED
@@ -23,8 +23,10 @@ export function initShopify(config: { storeName: string; storefrontAccessToken:
23
23
  * Initialize Shopify from a blocks map (convenience wrapper).
24
24
  * Looks for the "deco-shopify" block and extracts credentials.
25
25
  */
26
- export function initShopifyFromBlocks(blocks: Record<string, any>) {
27
- const shopifyBlock = blocks["deco-shopify"];
26
+ export function initShopifyFromBlocks(blocks: Record<string, unknown>) {
27
+ const shopifyBlock = blocks["deco-shopify"] as
28
+ | { storeName: string; storefrontAccessToken: string }
29
+ | undefined;
28
30
  if (!shopifyBlock) {
29
31
  console.warn("[Shopify] No deco-shopify block found.");
30
32
  return;
@@ -37,8 +37,8 @@ export type Props = {
37
37
  metafields?: Metafield[];
38
38
  };
39
39
 
40
- const isQueryList = (p: any): p is QueryProps =>
41
- typeof p.query === "string" && typeof p.count === "number";
40
+ const isQueryList = (p: QueryProps | CollectionProps): p is QueryProps =>
41
+ "query" in p && typeof p.query === "string" && typeof p.count === "number";
42
42
 
43
43
  export default async function productListLoader(
44
44
  expandedProps: Props,
@@ -52,19 +52,23 @@ export default async function productListLoader(
52
52
  const metafields = expandedProps.metafields || [];
53
53
  const sort = props.sort ?? "";
54
54
 
55
- const filters: any[] = [];
56
- expandedProps.filters?.tags?.forEach((tag) => filters.push({ tag }));
57
- expandedProps.filters?.productTypes?.forEach((productType) => filters.push({ productType }));
58
- expandedProps.filters?.productVendors?.forEach((productVendor) =>
59
- filters.push({ productVendor }),
60
- );
55
+ const filters: Record<string, unknown>[] = [];
56
+ for (const tag of expandedProps.filters?.tags ?? []) {
57
+ filters.push({ tag });
58
+ }
59
+ for (const productType of expandedProps.filters?.productTypes ?? []) {
60
+ filters.push({ productType });
61
+ }
62
+ for (const productVendor of expandedProps.filters?.productVendors ?? []) {
63
+ filters.push({ productVendor });
64
+ }
61
65
  if (expandedProps.filters?.priceMin != null)
62
66
  filters.push({ price: { min: expandedProps.filters.priceMin } });
63
67
  if (expandedProps.filters?.priceMax != null)
64
68
  filters.push({ price: { max: expandedProps.filters.priceMax } });
65
- expandedProps.filters?.variantOptions?.forEach((variantOption) =>
66
- filters.push({ variantOption }),
67
- );
69
+ for (const variantOption of expandedProps.filters?.variantOptions ?? []) {
70
+ filters.push({ variantOption });
71
+ }
68
72
 
69
73
  let shopifyProducts: { nodes: ProductShopify[] } | undefined;
70
74
 
package/shopify/mod.ts CHANGED
@@ -36,7 +36,7 @@ export interface ShopifyState {
36
36
  * Returns an AppDefinition or null if required fields are missing.
37
37
  */
38
38
  export async function configure(
39
- block: any,
39
+ block: Record<string, unknown>,
40
40
  resolveSecret: ResolveSecretFn,
41
41
  ): Promise<AppDefinition<ShopifyState> | null> {
42
42
  if (!block?.storeName) return null;
@@ -48,9 +48,9 @@ export async function configure(
48
48
  if (!storefrontAccessToken) return null;
49
49
 
50
50
  const config: ShopifyConfig = {
51
- storeName: block.storeName,
51
+ storeName: block.storeName as string,
52
52
  storefrontAccessToken,
53
- publicUrl: block.publicUrl,
53
+ publicUrl: block.publicUrl as string | undefined,
54
54
  };
55
55
 
56
56
  // Bridge: maintain global singleton for backward compat
@@ -1,5 +1,6 @@
1
1
  // deno-fmt-ignore-file
2
2
  // deno-lint-ignore-file no-explicit-any ban-types ban-unused-ignore
3
+ // biome-ignore-all lint/suspicious/noExplicitAny: generated GraphQL scalar types
3
4
 
4
5
  export type Maybe<T> = T | null;
5
6
 
@@ -215,7 +215,7 @@ export async function updateAddress(
215
215
  // Handle cookie extraction, postalCode sanitization, and field defaults.
216
216
  // ---------------------------------------------------------------------------
217
217
 
218
- import { getVtexCookies, ensureUnsuffixedAuthCookie } from "../utils/cookies";
218
+ import { ensureUnsuffixedAuthCookie, getVtexCookies } from "../utils/cookies";
219
219
 
220
220
  function sanitizeAddressInput(props: Record<string, any>): Record<string, any> {
221
221
  if (props.postalCode) props.postalCode = props.postalCode.replace(/\D/g, "");
@@ -224,18 +224,27 @@ function sanitizeAddressInput(props: Record<string, any>): Record<string, any> {
224
224
  return props;
225
225
  }
226
226
 
227
- export async function createAddressFromRequest(props: Record<string, any>, request: Request): Promise<SavedAddress> {
227
+ export async function createAddressFromRequest(
228
+ props: Record<string, any>,
229
+ request: Request,
230
+ ): Promise<SavedAddress> {
228
231
  const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
229
232
  return createAddress(sanitizeAddressInput(props) as AddressInput, cookie);
230
233
  }
231
234
 
232
- export async function updateAddressFromRequest(props: Record<string, any>, request: Request): Promise<UpdateAddressResult> {
235
+ export async function updateAddressFromRequest(
236
+ props: Record<string, any>,
237
+ request: Request,
238
+ ): Promise<UpdateAddressResult> {
233
239
  const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
234
240
  const { addressId, ...fields } = props;
235
241
  return updateAddress(addressId, fields, cookie);
236
242
  }
237
243
 
238
- export async function deleteAddressFromRequest(props: Record<string, any>, request: Request): Promise<DeleteAddressResult> {
244
+ export async function deleteAddressFromRequest(
245
+ props: Record<string, any>,
246
+ request: Request,
247
+ ): Promise<DeleteAddressResult> {
239
248
  const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
240
249
  return deleteAddress(props.addressId, cookie);
241
250
  }
@@ -122,10 +122,10 @@ export async function updateProfile(
122
122
  // Request-aware wrappers (for COMMERCE_LOADERS / invoke proxy)
123
123
  // ---------------------------------------------------------------------------
124
124
 
125
+ import { getCurrentProfile } from "../loaders/profile";
125
126
  import { getVtexCookies } from "../utils/cookies";
126
- import { updateNewsletterOptIn } from "./newsletter";
127
127
  import { deletePaymentToken } from "./misc";
128
- import { getCurrentProfile } from "../loaders/profile";
128
+ import { updateNewsletterOptIn } from "./newsletter";
129
129
 
130
130
  /**
131
131
  * Normalize birthDate strings to ISO 8601.
@@ -148,7 +148,10 @@ function normalizeBirthDate(profile: Record<string, any>): void {
148
148
  * Update user profile via VTEX IO GraphQL. Handles cookie extraction,
149
149
  * birthDate normalization, and undefined-key cleanup.
150
150
  */
151
- export async function updateProfileFromRequest(props: Record<string, any>, request: Request): Promise<any> {
151
+ export async function updateProfileFromRequest(
152
+ props: Record<string, any>,
153
+ request: Request,
154
+ ): Promise<any> {
152
155
  const { account } = getVtexConfig();
153
156
  const cookie = getVtexCookies(request);
154
157
  const profile = { ...props };
@@ -171,17 +174,26 @@ export async function updateProfileFromRequest(props: Record<string, any>, reque
171
174
  return res.json();
172
175
  }
173
176
 
174
- export async function newsletterProfileFromRequest(props: Record<string, any>, request: Request): Promise<any> {
177
+ export async function newsletterProfileFromRequest(
178
+ props: Record<string, any>,
179
+ request: Request,
180
+ ): Promise<any> {
175
181
  const cookie = request.headers.get("cookie") ?? "";
176
182
  return updateNewsletterOptIn(props.isNewsletterOptIn, props.email, cookie);
177
183
  }
178
184
 
179
- export async function deletePaymentFromRequest(props: Record<string, any>, request: Request): Promise<any> {
185
+ export async function deletePaymentFromRequest(
186
+ props: Record<string, any>,
187
+ request: Request,
188
+ ): Promise<any> {
180
189
  const cookie = getVtexCookies(request);
181
190
  return deletePaymentToken(props.id, cookie);
182
191
  }
183
192
 
184
- export async function getPasswordLastUpdate(_props: Record<string, any>, request: Request): Promise<string | null> {
193
+ export async function getPasswordLastUpdate(
194
+ _props: Record<string, any>,
195
+ request: Request,
196
+ ): Promise<string | null> {
185
197
  const cookie = getVtexCookies(request);
186
198
  const profile = await getCurrentProfile(cookie);
187
199
  return profile?.passwordLastUpdate ?? null;
package/vtex/client.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { RequestContext } from "@decocms/start/sdk/requestContext";
7
7
  import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache";
8
+ import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch";
8
9
  import { parseSegment, SEGMENT_COOKIE_NAME } from "./utils/segment";
9
10
 
10
11
  /**
@@ -252,13 +253,16 @@ export async function vtexFetchWithCookies<T>(path: string, init?: RequestInit):
252
253
  const response = await vtexFetchResponse(path, init);
253
254
  const data = (await response.json()) as T;
254
255
 
255
- // Forward Set-Cookie headers to RequestContext.responseHeaders
256
- // (mirrors proxySetCookie from deco-cx/deco)
256
+ // Forward Set-Cookie headers to RequestContext.responseHeaders,
257
+ // but skip VTEX internal IS cookies (managed server-side by the middleware).
257
258
  const responseHeaders = getResponseHeaders();
258
259
  if (responseHeaders) {
259
260
  const setCookies =
260
261
  typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
261
262
  for (const cookie of setCookies) {
263
+ if (cookie.startsWith(`${SESSION_COOKIE}=`) || cookie.startsWith(`${ANONYMOUS_COOKIE}=`)) {
264
+ continue;
265
+ }
262
266
  const stripped = cookie.replace(/;\s*domain=[^;]*/gi, "");
263
267
  responseHeaders.append("set-cookie", stripped);
264
268
  }
@@ -11,12 +11,12 @@
11
11
 
12
12
  import { createCachedLoader } from "@decocms/start/sdk/cachedLoader";
13
13
  import type { CacheProfileName } from "@decocms/start/sdk/cacheHeaders";
14
- import vtexProductList from "./inline-loaders/productList";
15
- import vtexProductListShelf from "./inline-loaders/productListShelf";
16
14
  import vtexProductDetailsPage from "./inline-loaders/productDetailsPage";
15
+ import vtexProductList from "./inline-loaders/productList";
17
16
  import vtexProductListingPage from "./inline-loaders/productListingPage";
18
- import vtexSuggestions from "./inline-loaders/suggestions";
17
+ import vtexProductListShelf from "./inline-loaders/productListShelf";
19
18
  import vtexRelatedProducts from "./inline-loaders/relatedProducts";
19
+ import vtexSuggestions from "./inline-loaders/suggestions";
20
20
  import vtexWorkflowProducts from "./inline-loaders/workflowProducts";
21
21
  import { getCategoryTree } from "./loaders/catalog";
22
22
  import { VALID_IS_SORTS } from "./utils/intelligentSearch";
@@ -51,16 +51,10 @@ function pdpWithSlugFallback(props: any): Promise<any> {
51
51
  * Extract collection name from PLP product data.
52
52
  * Products carry cluster info in additionalProperty with name="cluster".
53
53
  */
54
- function extractCollectionName(
55
- result: any,
56
- collectionId: string,
57
- ): string | null {
54
+ function extractCollectionName(result: any, collectionId: string): string | null {
58
55
  if (!result?.products?.length) return null;
59
56
  for (const product of result.products) {
60
- const props =
61
- product.additionalProperty ||
62
- product.isVariantOf?.additionalProperty ||
63
- [];
57
+ const props = product.additionalProperty || product.isVariantOf?.additionalProperty || [];
64
58
  for (const prop of props) {
65
59
  if (prop.name === "cluster" && prop.propertyID === collectionId) {
66
60
  return prop.value || null;
@@ -147,11 +141,7 @@ export function createVtexCommerceLoaders(
147
141
  const segments = props.__pagePath.split("/").filter(Boolean);
148
142
  const mapValues = mapParam.split(",");
149
143
  const facets: Array<{ key: string; value: string }> = [];
150
- for (
151
- let i = 0;
152
- i < Math.min(segments.length, mapValues.length);
153
- i++
154
- ) {
144
+ for (let i = 0; i < Math.min(segments.length, mapValues.length); i++) {
155
145
  const key = mapValues[i].trim();
156
146
  const value = decodeURIComponent(segments[i]);
157
147
  if (key && value) facets.push({ key, value });
@@ -175,14 +165,9 @@ export function createVtexCommerceLoaders(
175
165
  __pageUrl: pageUrl.toString(),
176
166
  });
177
167
 
178
- const clusterFacet = facets.find(
179
- (f) => f.key === "productClusterIds",
180
- );
168
+ const clusterFacet = facets.find((f) => f.key === "productClusterIds");
181
169
  if (result && clusterFacet) {
182
- const collectionName = extractCollectionName(
183
- result,
184
- clusterFacet.value,
185
- );
170
+ const collectionName = extractCollectionName(result, clusterFacet.value);
186
171
  if (collectionName) {
187
172
  result.breadcrumb = {
188
173
  "@type": "BreadcrumbList",
@@ -238,13 +223,10 @@ export function createVtexCommerceLoaders(
238
223
  "vtex/loaders/ProductDetailsPage.ts": cachedPDP,
239
224
  "vtex/loaders/ProductListingPage.ts": cachedPLP,
240
225
  // Category tree
241
- "vtex/loaders/categories/tree": (props: any) =>
242
- getCategoryTree(props?.categoryLevels ?? 3),
226
+ "vtex/loaders/categories/tree": (props: any) => getCategoryTree(props?.categoryLevels ?? 3),
243
227
  // Commerce passthrough loaders
244
228
  "commerce/loaders/navbar.ts": async (props: any) => props.items ?? [],
245
- "commerce/loaders/product/extensions/detailsPage.ts": async (
246
- props: any,
247
- ) => {
229
+ "commerce/loaders/product/extensions/detailsPage.ts": async (props: any) => {
248
230
  const data = props.data;
249
231
  if (data?.product) return data;
250
232
  return cachedPDP({ __pagePath: props.__pagePath });
@@ -274,12 +256,6 @@ export function createVtexCommerceLoaders(
274
256
  *
275
257
  * Returns a new instance each call — sites should cache the reference.
276
258
  */
277
- export function createCachedPDPLoader(
278
- profile: CacheProfileName = "product",
279
- ): CommerceLoaderFn {
280
- return createCachedLoader(
281
- "vtex/productDetailsPage",
282
- pdpWithSlugFallback,
283
- profile,
284
- );
259
+ export function createCachedPDPLoader(profile: CacheProfileName = "product"): CommerceLoaderFn {
260
+ return createCachedLoader("vtex/productDetailsPage", pdpWithSlugFallback, profile);
285
261
  }
@@ -53,6 +53,7 @@ export default async function vtexProductDetailsPage(
53
53
  const page = toProductPage(product, sku, kitItems, {
54
54
  baseUrl,
55
55
  priceCurrency: "BRL",
56
+ leanVariants: true,
56
57
  });
57
58
 
58
59
  return {
@@ -8,51 +8,51 @@ import { getVtexConfig, intelligentSearch as vtexIS } from "../client";
8
8
  import { pickSku, toProduct as toSchemaProduct } from "../utils/transform";
9
9
 
10
10
  export interface AutocompleteProps {
11
- query: string;
12
- count?: number;
13
- showSponsored?: boolean;
14
- placement?: string;
15
- fuzzy?: string;
11
+ query: string;
12
+ count?: number;
13
+ showSponsored?: boolean;
14
+ placement?: string;
15
+ fuzzy?: string;
16
16
  }
17
17
 
18
18
  export interface AutocompleteResult {
19
- searches: Array<{ term: string; count: number; attributes?: any[] }>;
20
- products: any[];
19
+ searches: Array<{ term: string; count: number; attributes?: any[] }>;
20
+ products: any[];
21
21
  }
22
22
 
23
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: [] };
24
+ const query = props.query || "";
25
+ const count = props.count ?? 4;
26
+ if (!query.trim()) return { searches: [], products: [] };
27
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
- ]);
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
41
 
42
- const config = getVtexConfig();
43
- const baseUrl = config.publicUrl
44
- ? `https://${config.publicUrl}`
45
- : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
42
+ const config = getVtexConfig();
43
+ const baseUrl = config.publicUrl
44
+ ? `https://${config.publicUrl}`
45
+ : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
46
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
- }
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
58
  }
@@ -63,6 +63,8 @@ export interface VtexRequestContext {
63
63
  isSessionId: string;
64
64
  /** Intelligent Search anonymous cookie. */
65
65
  isAnonymousId: string;
66
+ /** Whether IS cookies were freshly generated (browser didn't send them). */
67
+ needsISCookies: boolean;
66
68
  }
67
69
 
68
70
  // -------------------------------------------------------------------------
@@ -125,8 +127,9 @@ export function extractVtexContext(request: Request): VtexRequestContext {
125
127
  const authToken = extractVtexAuthCookie(cookies);
126
128
  const authInfo = authToken ? parseVtexAuthToken(authToken) : null;
127
129
 
128
- const isSessionId = getCookieValue(cookies, SESSION_COOKIE) ?? generateUUID();
129
- const isAnonymousId = getCookieValue(cookies, ANONYMOUS_COOKIE) ?? generateUUID();
130
+ const existingSessionId = getCookieValue(cookies, SESSION_COOKIE);
131
+ const existingAnonymousId = getCookieValue(cookies, ANONYMOUS_COOKIE);
132
+ const needsISCookies = !existingSessionId || !existingAnonymousId;
130
133
 
131
134
  return {
132
135
  segment,
@@ -136,8 +139,9 @@ export function extractVtexContext(request: Request): VtexRequestContext {
136
139
  salesChannel: segment.channel ?? "1",
137
140
  regionId: segment.regionId ?? null,
138
141
  hasCustomPricing: Boolean(segment.priceTables && segment.priceTables.length > 0),
139
- isSessionId,
140
- isAnonymousId,
142
+ isSessionId: existingSessionId ?? generateUUID(),
143
+ isAnonymousId: existingAnonymousId ?? generateUUID(),
144
+ needsISCookies,
141
145
  };
142
146
  }
143
147
 
@@ -177,13 +181,14 @@ export function vtexCacheControl(
177
181
  // -------------------------------------------------------------------------
178
182
 
179
183
  /**
180
- * Ensure Intelligent Search cookies exist on the response.
181
- *
182
- * If the browser already has them, they are forwarded as-is.
183
- * If not, new UUIDs from the context are set. This ensures
184
- * every user has IS cookies for personalization and analytics.
184
+ * Set Intelligent Search cookies on the response only when the browser
185
+ * doesn't already have them. On subsequent requests where the cookies
186
+ * exist, this is a no-op keeping the response free of Set-Cookie
187
+ * headers so it remains cacheable at the CDN edge.
185
188
  */
186
189
  export function propagateISCookies(ctx: VtexRequestContext, response: Response): void {
190
+ if (!ctx.needsISCookies) return;
191
+
187
192
  const maxAge = ONE_YEAR_SECONDS;
188
193
  response.headers.append(
189
194
  "Set-Cookie",
@@ -23,12 +23,13 @@
23
23
  * });
24
24
  * ```
25
25
  */
26
- import { getVtexCookies } from "./cookies";
27
- import { getUser } from "../loaders/user";
28
- import { getCurrentProfile, type Profile } from "../loaders/profile";
26
+
27
+ import { detectDevice } from "@decocms/start/sdk/useDevice";
29
28
  import { getUserAddresses, type VtexAddress } from "../loaders/address";
30
29
  import { getUserPayments, type Payment } from "../loaders/payment";
31
- import { detectDevice } from "@decocms/start/sdk/useDevice";
30
+ import { getCurrentProfile, type Profile } from "../loaders/profile";
31
+ import { getUser } from "../loaders/user";
32
+ import { getVtexCookies } from "./cookies";
32
33
 
33
34
  type Device = "mobile" | "tablet" | "desktop";
34
35
 
@@ -11,11 +11,11 @@ import { getVtexConfig } from "../client";
11
11
  const DOMAIN_RE = /;\s*domain=[^;]*/gi;
12
12
 
13
13
  const VTEX_COOKIE_PREFIXES = [
14
- "vtex_session=",
15
- "vtex_segment=",
16
- "VtexIdclientAutCookie",
17
- "checkout.vtex.com",
18
- "CheckoutOrderFormOwnership",
14
+ "vtex_session=",
15
+ "vtex_segment=",
16
+ "VtexIdclientAutCookie",
17
+ "checkout.vtex.com",
18
+ "CheckoutOrderFormOwnership",
19
19
  ];
20
20
 
21
21
  /**
@@ -23,11 +23,11 @@ const VTEX_COOKIE_PREFIXES = [
23
23
  * Filters out analytics/CF cookies that can cause VTEX 503 errors.
24
24
  */
25
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("; ");
26
+ return raw
27
+ .split(";")
28
+ .map((c) => c.trim())
29
+ .filter((c) => VTEX_COOKIE_PREFIXES.some((prefix) => c.startsWith(prefix)))
30
+ .join("; ");
31
31
  }
32
32
 
33
33
  /**
@@ -35,16 +35,16 @@ export function extractVtexCookiesFromHeader(raw: string): string {
35
35
  * with the storefront domain instead of the VTEX domain.
36
36
  */
37
37
  export function stripCookieDomain(cookies: string[]): string[] {
38
- return cookies.map((c) => c.replace(DOMAIN_RE, ""));
38
+ return cookies.map((c) => c.replace(DOMAIN_RE, ""));
39
39
  }
40
40
 
41
41
  /** Standard VTEX cookies to expire on logout. */
42
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",
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
48
  ];
49
49
 
50
50
  /**
@@ -52,24 +52,21 @@ export const VTEX_LOGOUT_COOKIES = [
52
52
  * the Set-Cookie headers (with domain stripped) to expire auth cookies.
53
53
  */
54
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=/`;
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
58
 
59
- const res = await fetch(logoutUrl, {
60
- method: "GET",
61
- headers: { cookie: cookies },
62
- redirect: "manual",
63
- });
59
+ const res = await fetch(logoutUrl, {
60
+ method: "GET",
61
+ headers: { cookie: cookies },
62
+ redirect: "manual",
63
+ });
64
64
 
65
- const upstreamCookies = res.headers.getSetCookie?.() ?? [];
65
+ const upstreamCookies = res.headers.getSetCookie?.() ?? [];
66
66
 
67
- return {
68
- setCookies: [
69
- ...stripCookieDomain(upstreamCookies),
70
- ...VTEX_LOGOUT_COOKIES,
71
- ],
72
- };
67
+ return {
68
+ setCookies: [...stripCookieDomain(upstreamCookies), ...VTEX_LOGOUT_COOKIES],
69
+ };
73
70
  }
74
71
 
75
72
  /**
@@ -77,21 +74,18 @@ export async function performVtexLogout(cookies: string): Promise<{ setCookies:
77
74
  * Reads the VtexIdclientAutCookie_* cookie from a raw Cookie header.
78
75
  */
79
76
  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
- }
77
+ try {
78
+ const match = rawCookies.match(/VtexIdclientAutCookie_[^=]+=([^;]+)/);
79
+ if (!match) return null;
80
+ const token = match[1];
81
+ const parts = token.split(".");
82
+ if (parts.length < 2) return null;
83
+ const payload = JSON.parse(
84
+ Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"),
85
+ );
86
+ if (!payload.sub) return null;
87
+ return { email: payload.sub, userId: payload.userId ?? "" };
88
+ } catch {
89
+ return null;
90
+ }
97
91
  }
@@ -1,5 +1,5 @@
1
- export { vtexAccountLoaders } from "./accountLoaders";
2
1
  export type { PersonalDataOptions } from "./accountLoaders";
2
+ export { vtexAccountLoaders } from "./accountLoaders";
3
3
  export * from "./batch";
4
4
  export * from "./cookies";
5
5
  export * from "./enrichment";
@@ -286,17 +286,11 @@ function filterHeadersStrict(headers: Headers): Headers {
286
286
  * Unlike `proxySetCookie`, this preserves ALL attributes (Max-Age,
287
287
  * Expires, SameSite, etc.) which is critical for logout.
288
288
  */
289
- function rewriteSetCookieDomain(
290
- from: Headers,
291
- to: Headers,
292
- toHostname: string,
293
- ) {
289
+ function rewriteSetCookieDomain(from: Headers, to: Headers, toHostname: string) {
294
290
  const raw: string[] =
295
291
  typeof from.getSetCookie === "function"
296
292
  ? from.getSetCookie()
297
- : (from.get("set-cookie") ?? "")
298
- .split(/,(?=[^ ]+=)/)
299
- .filter(Boolean);
293
+ : (from.get("set-cookie") ?? "").split(/,(?=[^ ]+=)/).filter(Boolean);
300
294
 
301
295
  for (const cookie of raw) {
302
296
  const rewritten = cookie.replace(/Domain=[^;]*/i, `Domain=${toHostname}`);
@@ -342,11 +336,8 @@ export function createVtexCheckoutProxy(
342
336
  const checkoutOrigin = config.checkoutOrigin.startsWith("https://")
343
337
  ? config.checkoutOrigin
344
338
  : `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`;
339
+ const apiOrigin = config.apiOrigin ?? `https://${config.account}.vtexcommercestable.${domain}`;
340
+ const myvtexOrigin = config.myvtexOrigin ?? `https://${config.account}.myvtex.com`;
350
341
 
351
342
  function getOrigin(pathname: string, method: string): string {
352
343
  if (
@@ -375,8 +366,7 @@ export function createVtexCheckoutProxy(
375
366
  fwd.set("origin", request.headers.get("origin") ?? url.origin);
376
367
 
377
368
  const isCheckoutUI =
378
- url.pathname.startsWith("/checkout") ||
379
- url.pathname.startsWith("/account");
369
+ url.pathname.startsWith("/checkout") || url.pathname.startsWith("/account");
380
370
  const isLogout = url.pathname.startsWith("/api/vtexid/pub/logout");
381
371
 
382
372
  const init: RequestInit = {
@@ -399,10 +389,7 @@ export function createVtexCheckoutProxy(
399
389
  for (const rule of config.expireCookiesOnPaths) {
400
390
  if (url.pathname.startsWith(rule.pathPrefix)) {
401
391
  for (const name of rule.cookies) {
402
- resHeaders.append(
403
- "Set-Cookie",
404
- `${name}=; Path=/; Max-Age=0; Domain=${url.hostname}`,
405
- );
392
+ resHeaders.append("Set-Cookie", `${name}=; Path=/; Max-Age=0; Domain=${url.hostname}`);
406
393
  }
407
394
  }
408
395
  }
@@ -105,6 +105,10 @@ interface ProductOptions {
105
105
  imagesByKey?: Map<string, string>;
106
106
  /** Original attributes to be included in the transformed product */
107
107
  includeOriginalAttributes?: string[];
108
+ /** Use lean toProductVariant for hasVariant[] instead of full toProduct at level=1 */
109
+ leanVariants?: boolean;
110
+ /** Property names to keep on lean variant additionalProperty. Defaults to VARIANT_PROPERTY_NAMES. */
111
+ variantPropertyNames?: Set<string>;
108
112
  }
109
113
 
110
114
  /** Returns first available sku */
@@ -389,7 +393,9 @@ export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
389
393
  ? ({
390
394
  "@type": "ProductGroup",
391
395
  productGroupID: productId,
392
- hasVariant: items.map((sku) => toProduct(product, sku, 1, variantOptions)),
396
+ hasVariant: options.leanVariants
397
+ ? items.map((sku) => toProductVariant(product, sku, variantOptions))
398
+ : items.map((sku) => toProduct(product, sku, 1, variantOptions)),
393
399
  url: getProductGroupURL(baseUrl, product).href,
394
400
  name: product.productName,
395
401
  additionalProperty: [
@@ -659,6 +665,70 @@ export const toProductShelf = <P extends LegacyProductVTEX | ProductVTEX>(
659
665
  };
660
666
  };
661
667
 
668
+ /** Property names that differentiate SKU variants (used by variant selectors) */
669
+ const VARIANT_PROPERTY_NAMES = new Set(["Cor", "Voltagem", "Tamanho"]);
670
+
671
+ /**
672
+ * Build a minimal offer for variant display. Keeps only availability and seller.
673
+ * No priceSpecification, no inventoryLevel, no teasers.
674
+ */
675
+ const buildOfferVariant = (offer: Offer): Offer => ({
676
+ "@type": "Offer",
677
+ identifier: offer.identifier,
678
+ price: offer.price,
679
+ seller: offer.seller,
680
+ availability: offer.availability,
681
+ priceSpecification: [],
682
+ inventoryLevel: { value: 0 },
683
+ });
684
+
685
+ /**
686
+ * Minimal product transform for variant entries inside isVariantOf.hasVariant[].
687
+ *
688
+ * Keeps only what variant selectors need:
689
+ * - url, productID, sku, name, inProductGroupWithID
690
+ * - additionalProperty filtered to variant-differentiating props (Cor, Voltagem, Tamanho)
691
+ * - offers with availability + seller only (no price specs)
692
+ *
693
+ * Drops: images, description, video, brand, category, gtin, releaseDate,
694
+ * alternateName, isAccessoryOrSparePartFor, isVariantOf
695
+ */
696
+ export const toProductVariant = <P extends LegacyProductVTEX | ProductVTEX>(
697
+ product: P,
698
+ sku: P["items"][number],
699
+ options: ProductOptions,
700
+ ): Product => {
701
+ const { baseUrl, priceCurrency } = options;
702
+ const { productId } = product;
703
+ const { name, itemId: skuId } = sku;
704
+ const variantProps = options.variantPropertyNames ?? VARIANT_PROPERTY_NAMES;
705
+
706
+ // additionalProperty: only variant-differentiating specs
707
+ const specificationsAdditionalProperty = isLegacySku(sku)
708
+ ? toAdditionalPropertiesLegacy(sku)
709
+ : toAdditionalProperties(sku);
710
+ const additionalProperty = specificationsAdditionalProperty.filter((prop) =>
711
+ variantProps.has(prop.name ?? ""),
712
+ );
713
+
714
+ // Offers: all sellers but lean (availability + seller only)
715
+ const offerConverter = isLegacyProduct(product) ? toOfferLegacy : toOffer;
716
+ const allOffers = (sku.sellers ?? []).map(offerConverter).sort(bestOfferFirst);
717
+ const bestOffer = allOffers[0];
718
+ const leanOffers = bestOffer ? [buildOfferVariant(bestOffer)] : [];
719
+
720
+ return {
721
+ "@type": "Product",
722
+ productID: skuId,
723
+ sku: skuId,
724
+ name,
725
+ url: getProductURL(baseUrl, product, sku.itemId).href,
726
+ inProductGroupWithID: productId,
727
+ additionalProperty,
728
+ offers: aggregateOffers(leanOffers, priceCurrency),
729
+ };
730
+ };
731
+
662
732
  const toBreadcrumbList = (
663
733
  product: ProductVTEX | LegacyProductVTEX,
664
734
  { baseUrl }: ProductOptions,