diff --git a/commerce/sdk/analytics.ts b/commerce/sdk/analytics.ts index f3430a1..1795636 100644 --- a/commerce/sdk/analytics.ts +++ b/commerce/sdk/analytics.ts @@ -4,162 +4,148 @@ * These are the generic, platform-independent mappers. Sites can wrap them * to add custom fields (sellerP, etc.) via the `extend` option. */ -import type { BreadcrumbList, Product } from "../types"; +import type { BreadcrumbList, Product } from "../types/commerce"; export interface AnalyticsItem { - item_id?: string; - item_name?: string; - affiliation?: string; - coupon?: string; - discount?: number; - index?: number; - item_group_id?: string; - item_url?: string; - item_brand?: string; - item_category?: string; - item_category2?: string; - item_category3?: string; - item_category4?: string; - item_category5?: string; - item_list_id?: string; - item_list_name?: string; - item_variant?: string; - location_id?: string; - price?: number; - quantity: number; - [key: string]: unknown; + item_id?: string; + item_name?: string; + affiliation?: string; + coupon?: string; + discount?: number; + index?: number; + item_group_id?: string; + item_url?: string; + item_brand?: string; + item_category?: string; + item_category2?: string; + item_category3?: string; + item_category4?: string; + item_category5?: string; + item_list_id?: string; + item_list_name?: string; + item_variant?: string; + location_id?: string; + price?: number; + quantity: number; + [key: string]: unknown; } -export function mapCategoriesToAnalyticsCategories( - categories: string[], -): Record { - return categories.slice(0, 5).reduce( - (result, category, index) => { - result[`item_category${index === 0 ? "" : index + 1}`] = category; - return result; - }, - {} as Record, - ); +export function mapCategoriesToAnalyticsCategories(categories: string[]): Record { + return categories.slice(0, 5).reduce( + (result, category, index) => { + result[`item_category${index === 0 ? "" : index + 1}`] = category; + return result; + }, + {} as Record, + ); } export function mapProductCategoryToAnalyticsCategories(category: string): Record { - return category.split(">").reduce( - (result, cat, index) => { - result[`item_category${index === 0 ? "" : index}`] = cat.trim(); - return result; - }, - {} as Record, - ); + return category.split(">").reduce( + (result, cat, index) => { + result[`item_category${index === 0 ? "" : index}`] = cat.trim(); + return result; + }, + {} as Record, + ); } export interface MapProductToAnalyticsItemOptions { - product: Product; - breadcrumbList?: BreadcrumbList; - price?: number; - lowPrice?: number; - listPrice?: number; - index?: number; - quantity?: number; - coupon?: string; - /** Extend the result with custom fields (e.g., sellerP, sellerName) */ - extend?: (product: Product, base: AnalyticsItem) => Record; + product: Product; + breadcrumbList?: BreadcrumbList; + price?: number; + lowPrice?: number; + listPrice?: number; + index?: number; + quantity?: number; + coupon?: string; + /** Extend the result with custom fields (e.g., sellerP, sellerName) */ + extend?: (product: Product, base: AnalyticsItem) => Record; } export function mapProductToAnalyticsItem(opts: MapProductToAnalyticsItemOptions): AnalyticsItem { - const { - product, - breadcrumbList, - price, - lowPrice, - listPrice, - index = 0, - quantity = 1, - coupon = "", - extend, - } = opts; - - const { name, productID, inProductGroupWithID, isVariantOf, url, sku } = product; - - const categories = breadcrumbList?.itemListElement - ? mapCategoriesToAnalyticsCategories( - breadcrumbList.itemListElement - .map(({ name: n }) => n ?? "") - .filter(Boolean), - ) - : mapProductCategoryToAnalyticsCategories(product.category ?? ""); - - const base: AnalyticsItem = { - item_id: productID, - item_group_id: inProductGroupWithID, - quantity, - coupon, - price: lowPrice, - index, - item_variant: sku, - discount: Number((price && listPrice ? listPrice - price : 0).toFixed(2)), - item_name: isVariantOf?.name ?? name ?? "", - item_brand: product.brand?.name ?? "", - item_url: url, - ...categories, - }; - - if (extend) { - return { ...base, ...extend(product, base) }; - } - - return base; + const { + product, + breadcrumbList, + price, + lowPrice, + listPrice, + index = 0, + quantity = 1, + coupon = "", + extend, + } = opts; + + const { name, productID, inProductGroupWithID, isVariantOf, url, sku } = product; + + const categories = breadcrumbList?.itemListElement + ? mapCategoriesToAnalyticsCategories( + breadcrumbList.itemListElement.map(({ name: n }) => n ?? "").filter(Boolean), + ) + : mapProductCategoryToAnalyticsCategories(product.category ?? ""); + + const base: AnalyticsItem = { + item_id: productID, + item_group_id: inProductGroupWithID, + quantity, + coupon, + price: lowPrice, + index, + item_variant: sku, + discount: Number((price && listPrice ? listPrice - price : 0).toFixed(2)), + item_name: isVariantOf?.name ?? name ?? "", + item_brand: product.brand?.name ?? "", + item_url: url, + ...categories, + }; + + if (extend) { + return { ...base, ...extend(product, base) }; + } + + return base; } export interface MapProductToAnalyticsItemListOptions { - product: Product; - breadcrumbList?: BreadcrumbList; - price?: number; - listPrice?: number; - index?: number; - quantity?: number; - coupon?: string; + product: Product; + breadcrumbList?: BreadcrumbList; + price?: number; + listPrice?: number; + index?: number; + quantity?: number; + coupon?: string; } -export function mapProductToAnalyticsItemList(opts: MapProductToAnalyticsItemListOptions): AnalyticsItem { - const { - product, - breadcrumbList, - price, - listPrice, - index = 0, - quantity = 1, - coupon = "", - } = opts; - - const { name, productID, inProductGroupWithID, isVariantOf, url } = product; - - const categories = breadcrumbList?.itemListElement - ? mapCategoriesToAnalyticsCategories( - breadcrumbList.itemListElement - .map(({ name: n }) => n ?? "") - .filter(Boolean), - ) - : mapProductCategoryToAnalyticsCategories(product.category ?? ""); - - const finalPrice = typeof price === "number" ? price : 0; - const discount = - typeof listPrice === "number" && typeof price === "number" - ? Math.max(0, listPrice - price) - : 0; - - const itemId = inProductGroupWithID ?? isVariantOf?.productGroupID ?? productID; - - return { - item_id: itemId, - item_group_id: inProductGroupWithID, - quantity, - coupon, - price: finalPrice, - index, - discount: Number(discount.toFixed(2)), - item_name: isVariantOf?.name ?? name ?? "", - item_brand: product.brand?.name ?? "", - item_url: url, - ...categories, - }; +export function mapProductToAnalyticsItemList( + opts: MapProductToAnalyticsItemListOptions, +): AnalyticsItem { + const { product, breadcrumbList, price, listPrice, index = 0, quantity = 1, coupon = "" } = opts; + + const { name, productID, inProductGroupWithID, isVariantOf, url } = product; + + const categories = breadcrumbList?.itemListElement + ? mapCategoriesToAnalyticsCategories( + breadcrumbList.itemListElement.map(({ name: n }) => n ?? "").filter(Boolean), + ) + : mapProductCategoryToAnalyticsCategories(product.category ?? ""); + + const finalPrice = typeof price === "number" ? price : 0; + const discount = + typeof listPrice === "number" && typeof price === "number" ? Math.max(0, listPrice - price) : 0; + + const itemId = inProductGroupWithID ?? isVariantOf?.productGroupID ?? productID; + + return { + item_id: itemId, + item_group_id: inProductGroupWithID, + quantity, + coupon, + price: finalPrice, + index, + discount: Number(discount.toFixed(2)), + item_name: isVariantOf?.name ?? name ?? "", + item_brand: product.brand?.name ?? "", + item_url: url, + ...categories, + }; } diff --git a/shopify/init.ts b/shopify/init.ts index 9e9fae2..5cdafe9 100644 --- a/shopify/init.ts +++ b/shopify/init.ts @@ -23,8 +23,10 @@ export function initShopify(config: { storeName: string; storefrontAccessToken: * Initialize Shopify from a blocks map (convenience wrapper). * Looks for the "deco-shopify" block and extracts credentials. */ -export function initShopifyFromBlocks(blocks: Record) { - const shopifyBlock = blocks["deco-shopify"]; +export function initShopifyFromBlocks(blocks: Record) { + const shopifyBlock = blocks["deco-shopify"] as + | { storeName: string; storefrontAccessToken: string } + | undefined; if (!shopifyBlock) { console.warn("[Shopify] No deco-shopify block found."); return; diff --git a/shopify/loaders/ProductList.ts b/shopify/loaders/ProductList.ts index 1710886..93fc308 100644 --- a/shopify/loaders/ProductList.ts +++ b/shopify/loaders/ProductList.ts @@ -37,8 +37,8 @@ export type Props = { metafields?: Metafield[]; }; -const isQueryList = (p: any): p is QueryProps => - typeof p.query === "string" && typeof p.count === "number"; +const isQueryList = (p: QueryProps | CollectionProps): p is QueryProps => + "query" in p && typeof p.query === "string" && typeof p.count === "number"; export default async function productListLoader( expandedProps: Props, @@ -52,19 +52,23 @@ export default async function productListLoader( const metafields = expandedProps.metafields || []; const sort = props.sort ?? ""; - const filters: any[] = []; - expandedProps.filters?.tags?.forEach((tag) => filters.push({ tag })); - expandedProps.filters?.productTypes?.forEach((productType) => filters.push({ productType })); - expandedProps.filters?.productVendors?.forEach((productVendor) => - filters.push({ productVendor }), - ); + const filters: Record[] = []; + for (const tag of expandedProps.filters?.tags ?? []) { + filters.push({ tag }); + } + for (const productType of expandedProps.filters?.productTypes ?? []) { + filters.push({ productType }); + } + for (const productVendor of expandedProps.filters?.productVendors ?? []) { + filters.push({ productVendor }); + } if (expandedProps.filters?.priceMin != null) filters.push({ price: { min: expandedProps.filters.priceMin } }); if (expandedProps.filters?.priceMax != null) filters.push({ price: { max: expandedProps.filters.priceMax } }); - expandedProps.filters?.variantOptions?.forEach((variantOption) => - filters.push({ variantOption }), - ); + for (const variantOption of expandedProps.filters?.variantOptions ?? []) { + filters.push({ variantOption }); + } let shopifyProducts: { nodes: ProductShopify[] } | undefined; diff --git a/shopify/mod.ts b/shopify/mod.ts index 8ce5820..d4e8c1b 100644 --- a/shopify/mod.ts +++ b/shopify/mod.ts @@ -36,7 +36,7 @@ export interface ShopifyState { * Returns an AppDefinition or null if required fields are missing. */ export async function configure( - block: any, + block: Record, resolveSecret: ResolveSecretFn, ): Promise | null> { if (!block?.storeName) return null; @@ -48,9 +48,9 @@ export async function configure( if (!storefrontAccessToken) return null; const config: ShopifyConfig = { - storeName: block.storeName, + storeName: block.storeName as string, storefrontAccessToken, - publicUrl: block.publicUrl, + publicUrl: block.publicUrl as string | undefined, }; // Bridge: maintain global singleton for backward compat diff --git a/shopify/utils/admin/admin.ts b/shopify/utils/admin/admin.ts index 33060b4..6397133 100644 --- a/shopify/utils/admin/admin.ts +++ b/shopify/utils/admin/admin.ts @@ -1,5 +1,6 @@ // deno-fmt-ignore-file // deno-lint-ignore-file no-explicit-any ban-types ban-unused-ignore +// biome-ignore-all lint/suspicious/noExplicitAny: generated GraphQL scalar types export type Maybe = T | null; diff --git a/vtex/actions/address.ts b/vtex/actions/address.ts index 97e8149..af28a4c 100644 --- a/vtex/actions/address.ts +++ b/vtex/actions/address.ts @@ -215,7 +215,7 @@ export async function updateAddress( // Handle cookie extraction, postalCode sanitization, and field defaults. // --------------------------------------------------------------------------- -import { getVtexCookies, ensureUnsuffixedAuthCookie } from "../utils/cookies"; +import { ensureUnsuffixedAuthCookie, getVtexCookies } from "../utils/cookies"; function sanitizeAddressInput(props: Record): Record { if (props.postalCode) props.postalCode = props.postalCode.replace(/\D/g, ""); @@ -224,18 +224,27 @@ function sanitizeAddressInput(props: Record): Record { return props; } -export async function createAddressFromRequest(props: Record, request: Request): Promise { +export async function createAddressFromRequest( + props: Record, + request: Request, +): Promise { const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request)); return createAddress(sanitizeAddressInput(props) as AddressInput, cookie); } -export async function updateAddressFromRequest(props: Record, request: Request): Promise { +export async function updateAddressFromRequest( + props: Record, + request: Request, +): Promise { const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request)); const { addressId, ...fields } = props; return updateAddress(addressId, fields, cookie); } -export async function deleteAddressFromRequest(props: Record, request: Request): Promise { +export async function deleteAddressFromRequest( + props: Record, + request: Request, +): Promise { const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request)); return deleteAddress(props.addressId, cookie); } diff --git a/vtex/actions/profile.ts b/vtex/actions/profile.ts index 310a8dd..e16294a 100644 --- a/vtex/actions/profile.ts +++ b/vtex/actions/profile.ts @@ -122,10 +122,10 @@ export async function updateProfile( // Request-aware wrappers (for COMMERCE_LOADERS / invoke proxy) // --------------------------------------------------------------------------- +import { getCurrentProfile } from "../loaders/profile"; import { getVtexCookies } from "../utils/cookies"; -import { updateNewsletterOptIn } from "./newsletter"; import { deletePaymentToken } from "./misc"; -import { getCurrentProfile } from "../loaders/profile"; +import { updateNewsletterOptIn } from "./newsletter"; /** * Normalize birthDate strings to ISO 8601. @@ -148,7 +148,10 @@ function normalizeBirthDate(profile: Record): void { * Update user profile via VTEX IO GraphQL. Handles cookie extraction, * birthDate normalization, and undefined-key cleanup. */ -export async function updateProfileFromRequest(props: Record, request: Request): Promise { +export async function updateProfileFromRequest( + props: Record, + request: Request, +): Promise { const { account } = getVtexConfig(); const cookie = getVtexCookies(request); const profile = { ...props }; @@ -171,17 +174,26 @@ export async function updateProfileFromRequest(props: Record, reque return res.json(); } -export async function newsletterProfileFromRequest(props: Record, request: Request): Promise { +export async function newsletterProfileFromRequest( + props: Record, + request: Request, +): Promise { const cookie = request.headers.get("cookie") ?? ""; return updateNewsletterOptIn(props.isNewsletterOptIn, props.email, cookie); } -export async function deletePaymentFromRequest(props: Record, request: Request): Promise { +export async function deletePaymentFromRequest( + props: Record, + request: Request, +): Promise { const cookie = getVtexCookies(request); return deletePaymentToken(props.id, cookie); } -export async function getPasswordLastUpdate(_props: Record, request: Request): Promise { +export async function getPasswordLastUpdate( + _props: Record, + request: Request, +): Promise { const cookie = getVtexCookies(request); const profile = await getCurrentProfile(cookie); return profile?.passwordLastUpdate ?? null; diff --git a/vtex/client.ts b/vtex/client.ts index 57569ba..0f2722a 100644 --- a/vtex/client.ts +++ b/vtex/client.ts @@ -5,6 +5,7 @@ import { RequestContext } from "@decocms/start/sdk/requestContext"; import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache"; +import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch"; import { parseSegment, SEGMENT_COOKIE_NAME } from "./utils/segment"; /** @@ -252,13 +253,16 @@ export async function vtexFetchWithCookies(path: string, init?: RequestInit): const response = await vtexFetchResponse(path, init); const data = (await response.json()) as T; - // Forward Set-Cookie headers to RequestContext.responseHeaders - // (mirrors proxySetCookie from deco-cx/deco) + // Forward Set-Cookie headers to RequestContext.responseHeaders, + // but skip VTEX internal IS cookies (managed server-side by the middleware). const responseHeaders = getResponseHeaders(); if (responseHeaders) { const setCookies = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []; for (const cookie of setCookies) { + if (cookie.startsWith(`${SESSION_COOKIE}=`) || cookie.startsWith(`${ANONYMOUS_COOKIE}=`)) { + continue; + } const stripped = cookie.replace(/;\s*domain=[^;]*/gi, ""); responseHeaders.append("set-cookie", stripped); } diff --git a/vtex/commerceLoaders.ts b/vtex/commerceLoaders.ts index e571a95..784242e 100644 --- a/vtex/commerceLoaders.ts +++ b/vtex/commerceLoaders.ts @@ -11,12 +11,12 @@ import { createCachedLoader } from "@decocms/start/sdk/cachedLoader"; import type { CacheProfileName } from "@decocms/start/sdk/cacheHeaders"; -import vtexProductList from "./inline-loaders/productList"; -import vtexProductListShelf from "./inline-loaders/productListShelf"; import vtexProductDetailsPage from "./inline-loaders/productDetailsPage"; +import vtexProductList from "./inline-loaders/productList"; import vtexProductListingPage from "./inline-loaders/productListingPage"; -import vtexSuggestions from "./inline-loaders/suggestions"; +import vtexProductListShelf from "./inline-loaders/productListShelf"; import vtexRelatedProducts from "./inline-loaders/relatedProducts"; +import vtexSuggestions from "./inline-loaders/suggestions"; import vtexWorkflowProducts from "./inline-loaders/workflowProducts"; import { getCategoryTree } from "./loaders/catalog"; import { VALID_IS_SORTS } from "./utils/intelligentSearch"; @@ -51,16 +51,10 @@ function pdpWithSlugFallback(props: any): Promise { * Extract collection name from PLP product data. * Products carry cluster info in additionalProperty with name="cluster". */ -function extractCollectionName( - result: any, - collectionId: string, -): string | null { +function extractCollectionName(result: any, collectionId: string): string | null { if (!result?.products?.length) return null; for (const product of result.products) { - const props = - product.additionalProperty || - product.isVariantOf?.additionalProperty || - []; + const props = product.additionalProperty || product.isVariantOf?.additionalProperty || []; for (const prop of props) { if (prop.name === "cluster" && prop.propertyID === collectionId) { return prop.value || null; @@ -147,11 +141,7 @@ export function createVtexCommerceLoaders( const segments = props.__pagePath.split("/").filter(Boolean); const mapValues = mapParam.split(","); const facets: Array<{ key: string; value: string }> = []; - for ( - let i = 0; - i < Math.min(segments.length, mapValues.length); - i++ - ) { + for (let i = 0; i < Math.min(segments.length, mapValues.length); i++) { const key = mapValues[i].trim(); const value = decodeURIComponent(segments[i]); if (key && value) facets.push({ key, value }); @@ -175,14 +165,9 @@ export function createVtexCommerceLoaders( __pageUrl: pageUrl.toString(), }); - const clusterFacet = facets.find( - (f) => f.key === "productClusterIds", - ); + const clusterFacet = facets.find((f) => f.key === "productClusterIds"); if (result && clusterFacet) { - const collectionName = extractCollectionName( - result, - clusterFacet.value, - ); + const collectionName = extractCollectionName(result, clusterFacet.value); if (collectionName) { result.breadcrumb = { "@type": "BreadcrumbList", @@ -238,13 +223,10 @@ export function createVtexCommerceLoaders( "vtex/loaders/ProductDetailsPage.ts": cachedPDP, "vtex/loaders/ProductListingPage.ts": cachedPLP, // Category tree - "vtex/loaders/categories/tree": (props: any) => - getCategoryTree(props?.categoryLevels ?? 3), + "vtex/loaders/categories/tree": (props: any) => getCategoryTree(props?.categoryLevels ?? 3), // Commerce passthrough loaders "commerce/loaders/navbar.ts": async (props: any) => props.items ?? [], - "commerce/loaders/product/extensions/detailsPage.ts": async ( - props: any, - ) => { + "commerce/loaders/product/extensions/detailsPage.ts": async (props: any) => { const data = props.data; if (data?.product) return data; return cachedPDP({ __pagePath: props.__pagePath }); @@ -274,12 +256,6 @@ export function createVtexCommerceLoaders( * * Returns a new instance each call — sites should cache the reference. */ -export function createCachedPDPLoader( - profile: CacheProfileName = "product", -): CommerceLoaderFn { - return createCachedLoader( - "vtex/productDetailsPage", - pdpWithSlugFallback, - profile, - ); +export function createCachedPDPLoader(profile: CacheProfileName = "product"): CommerceLoaderFn { + return createCachedLoader("vtex/productDetailsPage", pdpWithSlugFallback, profile); } diff --git a/vtex/loaders/autocomplete.ts b/vtex/loaders/autocomplete.ts index a446667..7b4f786 100644 --- a/vtex/loaders/autocomplete.ts +++ b/vtex/loaders/autocomplete.ts @@ -8,51 +8,51 @@ import { getVtexConfig, intelligentSearch as vtexIS } from "../client"; import { pickSku, toProduct as toSchemaProduct } from "../utils/transform"; export interface AutocompleteProps { - query: string; - count?: number; - showSponsored?: boolean; - placement?: string; - fuzzy?: string; + query: string; + count?: number; + showSponsored?: boolean; + placement?: string; + fuzzy?: string; } export interface AutocompleteResult { - searches: Array<{ term: string; count: number; attributes?: any[] }>; - products: any[]; + searches: Array<{ term: string; count: number; attributes?: any[] }>; + products: any[]; } export async function autocompleteSearch(props: AutocompleteProps): Promise { - const query = props.query || ""; - const count = props.count ?? 4; - if (!query.trim()) return { searches: [], products: [] }; + const query = props.query || ""; + const count = props.count ?? 4; + if (!query.trim()) return { searches: [], products: [] }; - try { - const [suggestionsData, productsData] = await Promise.all([ - vtexIS<{ - searches: Array<{ term: string; count: number; attributes?: any[] }>; - }>("/autocomplete_suggestions/", { query }), - vtexIS<{ products: any[] }>("/product_search/", { - query, - count: String(count), - showSponsored: props.showSponsored !== false ? "true" : "false", - placement: props.placement ?? "top-search", - fuzzy: props.fuzzy ?? "0", - }), - ]); + try { + const [suggestionsData, productsData] = await Promise.all([ + vtexIS<{ + searches: Array<{ term: string; count: number; attributes?: any[] }>; + }>("/autocomplete_suggestions/", { query }), + vtexIS<{ products: any[] }>("/product_search/", { + query, + count: String(count), + showSponsored: props.showSponsored !== false ? "true" : "false", + placement: props.placement ?? "top-search", + fuzzy: props.fuzzy ?? "0", + }), + ]); - const config = getVtexConfig(); - const baseUrl = config.publicUrl - ? `https://${config.publicUrl}` - : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`; + const config = getVtexConfig(); + const baseUrl = config.publicUrl + ? `https://${config.publicUrl}` + : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`; - return { - searches: suggestionsData.searches ?? [], - products: (productsData.products ?? []).slice(0, count).map((p: any) => { - const sku = pickSku(p); - return toSchemaProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" }); - }), - }; - } catch (error) { - console.error("[vtex] autocompleteSearch error:", error); - return { searches: [], products: [] }; - } + return { + searches: suggestionsData.searches ?? [], + products: (productsData.products ?? []).slice(0, count).map((p: any) => { + const sku = pickSku(p); + return toSchemaProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" }); + }), + }; + } catch (error) { + console.error("[vtex] autocompleteSearch error:", error); + return { searches: [], products: [] }; + } } diff --git a/vtex/middleware.ts b/vtex/middleware.ts index 2916641..c03d2b2 100644 --- a/vtex/middleware.ts +++ b/vtex/middleware.ts @@ -63,6 +63,8 @@ export interface VtexRequestContext { isSessionId: string; /** Intelligent Search anonymous cookie. */ isAnonymousId: string; + /** Whether IS cookies were freshly generated (browser didn't send them). */ + needsISCookies: boolean; } // ------------------------------------------------------------------------- @@ -125,8 +127,9 @@ export function extractVtexContext(request: Request): VtexRequestContext { const authToken = extractVtexAuthCookie(cookies); const authInfo = authToken ? parseVtexAuthToken(authToken) : null; - const isSessionId = getCookieValue(cookies, SESSION_COOKIE) ?? generateUUID(); - const isAnonymousId = getCookieValue(cookies, ANONYMOUS_COOKIE) ?? generateUUID(); + const existingSessionId = getCookieValue(cookies, SESSION_COOKIE); + const existingAnonymousId = getCookieValue(cookies, ANONYMOUS_COOKIE); + const needsISCookies = !existingSessionId || !existingAnonymousId; return { segment, @@ -136,8 +139,9 @@ export function extractVtexContext(request: Request): VtexRequestContext { salesChannel: segment.channel ?? "1", regionId: segment.regionId ?? null, hasCustomPricing: Boolean(segment.priceTables && segment.priceTables.length > 0), - isSessionId, - isAnonymousId, + isSessionId: existingSessionId ?? generateUUID(), + isAnonymousId: existingAnonymousId ?? generateUUID(), + needsISCookies, }; } @@ -177,13 +181,14 @@ export function vtexCacheControl( // ------------------------------------------------------------------------- /** - * Ensure Intelligent Search cookies exist on the response. - * - * If the browser already has them, they are forwarded as-is. - * If not, new UUIDs from the context are set. This ensures - * every user has IS cookies for personalization and analytics. + * Set Intelligent Search cookies on the response only when the browser + * doesn't already have them. On subsequent requests where the cookies + * exist, this is a no-op — keeping the response free of Set-Cookie + * headers so it remains cacheable at the CDN edge. */ export function propagateISCookies(ctx: VtexRequestContext, response: Response): void { + if (!ctx.needsISCookies) return; + const maxAge = ONE_YEAR_SECONDS; response.headers.append( "Set-Cookie", diff --git a/vtex/utils/accountLoaders.ts b/vtex/utils/accountLoaders.ts index 4beb87e..5f79272 100644 --- a/vtex/utils/accountLoaders.ts +++ b/vtex/utils/accountLoaders.ts @@ -23,12 +23,13 @@ * }); * ``` */ -import { getVtexCookies } from "./cookies"; -import { getUser } from "../loaders/user"; -import { getCurrentProfile, type Profile } from "../loaders/profile"; + +import { detectDevice } from "@decocms/start/sdk/useDevice"; import { getUserAddresses, type VtexAddress } from "../loaders/address"; import { getUserPayments, type Payment } from "../loaders/payment"; -import { detectDevice } from "@decocms/start/sdk/useDevice"; +import { getCurrentProfile, type Profile } from "../loaders/profile"; +import { getUser } from "../loaders/user"; +import { getVtexCookies } from "./cookies"; type Device = "mobile" | "tablet" | "desktop"; diff --git a/vtex/utils/authHelpers.ts b/vtex/utils/authHelpers.ts index 88d0056..1e3b6c3 100644 --- a/vtex/utils/authHelpers.ts +++ b/vtex/utils/authHelpers.ts @@ -11,11 +11,11 @@ import { getVtexConfig } from "../client"; const DOMAIN_RE = /;\s*domain=[^;]*/gi; const VTEX_COOKIE_PREFIXES = [ - "vtex_session=", - "vtex_segment=", - "VtexIdclientAutCookie", - "checkout.vtex.com", - "CheckoutOrderFormOwnership", + "vtex_session=", + "vtex_segment=", + "VtexIdclientAutCookie", + "checkout.vtex.com", + "CheckoutOrderFormOwnership", ]; /** @@ -23,11 +23,11 @@ const VTEX_COOKIE_PREFIXES = [ * Filters out analytics/CF cookies that can cause VTEX 503 errors. */ export function extractVtexCookiesFromHeader(raw: string): string { - return raw - .split(";") - .map((c) => c.trim()) - .filter((c) => VTEX_COOKIE_PREFIXES.some((prefix) => c.startsWith(prefix))) - .join("; "); + return raw + .split(";") + .map((c) => c.trim()) + .filter((c) => VTEX_COOKIE_PREFIXES.some((prefix) => c.startsWith(prefix))) + .join("; "); } /** @@ -35,16 +35,16 @@ export function extractVtexCookiesFromHeader(raw: string): string { * with the storefront domain instead of the VTEX domain. */ export function stripCookieDomain(cookies: string[]): string[] { - return cookies.map((c) => c.replace(DOMAIN_RE, "")); + return cookies.map((c) => c.replace(DOMAIN_RE, "")); } /** Standard VTEX cookies to expire on logout. */ export const VTEX_LOGOUT_COOKIES = [ - "checkout.vtex.com=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax", - "CheckoutOrderFormOwnership=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax", - "checkout.vtex.com__orderFormId=; Path=/; Max-Age=0", - "vtex_session=; Path=/; Max-Age=0", - "vtex_segment=; Path=/; Max-Age=0", + "checkout.vtex.com=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax", + "CheckoutOrderFormOwnership=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax", + "checkout.vtex.com__orderFormId=; Path=/; Max-Age=0", + "vtex_session=; Path=/; Max-Age=0", + "vtex_segment=; Path=/; Max-Age=0", ]; /** @@ -52,24 +52,21 @@ export const VTEX_LOGOUT_COOKIES = [ * the Set-Cookie headers (with domain stripped) to expire auth cookies. */ export async function performVtexLogout(cookies: string): Promise<{ setCookies: string[] }> { - const config = getVtexConfig(); - const domain = config.domain ?? "com.br"; - const logoutUrl = `https://${config.account}.vtexcommercestable.${domain}/api/vtexid/pub/logout?scope=${config.account}&returnUrl=/`; + const config = getVtexConfig(); + const domain = config.domain ?? "com.br"; + const logoutUrl = `https://${config.account}.vtexcommercestable.${domain}/api/vtexid/pub/logout?scope=${config.account}&returnUrl=/`; - const res = await fetch(logoutUrl, { - method: "GET", - headers: { cookie: cookies }, - redirect: "manual", - }); + const res = await fetch(logoutUrl, { + method: "GET", + headers: { cookie: cookies }, + redirect: "manual", + }); - const upstreamCookies = res.headers.getSetCookie?.() ?? []; + const upstreamCookies = res.headers.getSetCookie?.() ?? []; - return { - setCookies: [ - ...stripCookieDomain(upstreamCookies), - ...VTEX_LOGOUT_COOKIES, - ], - }; + return { + setCookies: [...stripCookieDomain(upstreamCookies), ...VTEX_LOGOUT_COOKIES], + }; } /** @@ -77,21 +74,18 @@ export async function performVtexLogout(cookies: string): Promise<{ setCookies: * Reads the VtexIdclientAutCookie_* cookie from a raw Cookie header. */ export function parseVtexAuthJwt(rawCookies: string): { email: string; userId: string } | null { - try { - const match = rawCookies.match(/VtexIdclientAutCookie_[^=]+=([^;]+)/); - if (!match) return null; - const token = match[1]; - const parts = token.split("."); - if (parts.length < 2) return null; - const payload = JSON.parse( - Buffer.from( - parts[1].replace(/-/g, "+").replace(/_/g, "/"), - "base64", - ).toString("utf-8"), - ); - if (!payload.sub) return null; - return { email: payload.sub, userId: payload.userId ?? "" }; - } catch { - return null; - } + try { + const match = rawCookies.match(/VtexIdclientAutCookie_[^=]+=([^;]+)/); + if (!match) return null; + const token = match[1]; + const parts = token.split("."); + if (parts.length < 2) return null; + const payload = JSON.parse( + Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"), + ); + if (!payload.sub) return null; + return { email: payload.sub, userId: payload.userId ?? "" }; + } catch { + return null; + } } diff --git a/vtex/utils/index.ts b/vtex/utils/index.ts index 1acea86..c4f115d 100644 --- a/vtex/utils/index.ts +++ b/vtex/utils/index.ts @@ -1,5 +1,5 @@ -export { vtexAccountLoaders } from "./accountLoaders"; export type { PersonalDataOptions } from "./accountLoaders"; +export { vtexAccountLoaders } from "./accountLoaders"; export * from "./batch"; export * from "./cookies"; export * from "./enrichment"; diff --git a/vtex/utils/proxy.ts b/vtex/utils/proxy.ts index 4ea37fa..a4eb557 100644 --- a/vtex/utils/proxy.ts +++ b/vtex/utils/proxy.ts @@ -286,17 +286,11 @@ function filterHeadersStrict(headers: Headers): Headers { * Unlike `proxySetCookie`, this preserves ALL attributes (Max-Age, * Expires, SameSite, etc.) which is critical for logout. */ -function rewriteSetCookieDomain( - from: Headers, - to: Headers, - toHostname: string, -) { +function rewriteSetCookieDomain(from: Headers, to: Headers, toHostname: string) { const raw: string[] = typeof from.getSetCookie === "function" ? from.getSetCookie() - : (from.get("set-cookie") ?? "") - .split(/,(?=[^ ]+=)/) - .filter(Boolean); + : (from.get("set-cookie") ?? "").split(/,(?=[^ ]+=)/).filter(Boolean); for (const cookie of raw) { const rewritten = cookie.replace(/Domain=[^;]*/i, `Domain=${toHostname}`); @@ -342,11 +336,8 @@ export function createVtexCheckoutProxy( const checkoutOrigin = config.checkoutOrigin.startsWith("https://") ? config.checkoutOrigin : `https://${config.checkoutOrigin}`; - const apiOrigin = - config.apiOrigin ?? - `https://${config.account}.vtexcommercestable.${domain}`; - const myvtexOrigin = - config.myvtexOrigin ?? `https://${config.account}.myvtex.com`; + const apiOrigin = config.apiOrigin ?? `https://${config.account}.vtexcommercestable.${domain}`; + const myvtexOrigin = config.myvtexOrigin ?? `https://${config.account}.myvtex.com`; function getOrigin(pathname: string, method: string): string { if ( @@ -375,8 +366,7 @@ export function createVtexCheckoutProxy( fwd.set("origin", request.headers.get("origin") ?? url.origin); const isCheckoutUI = - url.pathname.startsWith("/checkout") || - url.pathname.startsWith("/account"); + url.pathname.startsWith("/checkout") || url.pathname.startsWith("/account"); const isLogout = url.pathname.startsWith("/api/vtexid/pub/logout"); const init: RequestInit = { @@ -399,10 +389,7 @@ export function createVtexCheckoutProxy( for (const rule of config.expireCookiesOnPaths) { if (url.pathname.startsWith(rule.pathPrefix)) { for (const name of rule.cookies) { - resHeaders.append( - "Set-Cookie", - `${name}=; Path=/; Max-Age=0; Domain=${url.hostname}`, - ); + resHeaders.append("Set-Cookie", `${name}=; Path=/; Max-Age=0; Domain=${url.hostname}`); } } }