diff --git a/vtex/loaders/intelligentSearch/productDetailsPage.ts b/vtex/loaders/intelligentSearch/productDetailsPage.ts index 62d1eb22f..41bddf923 100644 --- a/vtex/loaders/intelligentSearch/productDetailsPage.ts +++ b/vtex/loaders/intelligentSearch/productDetailsPage.ts @@ -3,6 +3,8 @@ import { STALE } from "../../../utils/fetch.ts"; import type { RequestURLParam } from "../../../website/functions/requestToParam.ts"; import { AppContext } from "../../mod.ts"; import { + isIntelligentSearchV1, + searchProducts, toPath, withDefaultFacets, withDefaultParams, @@ -11,9 +13,10 @@ import { pageTypesToSeo } from "../../utils/legacy.ts"; import { getSegmentCacheKeyWithoutUTM, getSegmentFromBag, - withSegmentCookie, + withSegmentParams, } from "../../utils/segment.ts"; import { withIsSimilarTo } from "../../utils/similars.ts"; +import { HttpError } from "../../../utils/http.ts"; import { pickSku, toProductPage } from "../../utils/transform.ts"; import type { AdvancedLoaderConfig, @@ -49,7 +52,7 @@ export interface Props { /** * When there's no ?skuId querystring, we need to figure out the product id - * from the pathname. For this, we use the pageType api + * from the pathname. For this, we use the pageType api (legacy IS flow only). */ const getProductID = (page: PageType) => { if (page.pageType !== "Product") { @@ -93,36 +96,70 @@ const loader = async ( const url = new URL(baseUrl); const skuId = url.searchParams.get("skuId"); - const productId = !skuId && getProductID(await pageTypePromise); - /** - * Fetch the exact skuId. If no one was provided, try fetching the product - * and return the first sku - */ - const query = skuId - ? `sku:${skuId}` - : productId - ? `product:${productId}` - : null; - - // In case we dont have the skuId or the productId, 404 - if (!query) { - return null; - } + let product: VTEXProduct | null = null; + + if (isIntelligentSearchV1(ctx)) { + // v1 exposes a dedicated single-product endpoint. Look it up by SKU when a + // skuId is present in the URL, otherwise by slug — no need to resolve the + // productId from the pageType API. + const [field, value] = skuId + ? (["sku", skuId] as const) + : (["slug", lowercaseSlug] as const); + + // Without a skuId or a slug there is nothing to look up, 404 + if (!value) { + return null; + } + + product = await vcsDeprecated + ["GET /api/intelligent-search/v1/products"]({ + field, + value, + locale, + simulationBehavior: props.simulationBehavior ?? "default", + ...withSegmentParams(segment), + // sc is required by this endpoint to resolve pricing/availability. + sc: segment?.payload?.channel ?? ctx.salesChannel ?? "1", + }, STALE) + .then((res) => res.json()) + .catch((error) => { + // A missing product resolves to a 404 on the v1 products endpoint; + // translate it into a not-found page instead of surfacing the error. + if (error instanceof HttpError && error.status === 404) { + return null; + } + throw error; + }); + } else { + // Legacy pipeline: resolve the productId from the pageType, then take the + // first product returned by product_search. + const productId = !skuId && getProductID(await pageTypePromise); + const query = skuId + ? `sku:${skuId}` + : productId + ? `product:${productId}` + : null; + + // In case we dont have the skuId or the productId, 404 + if (!query) { + return null; + } - const facets = withDefaultFacets([], ctx); - const params = withDefaultParams({ - query, - count: 1, - locale, - simulationBehavior: props.simulationBehavior ?? "default", - }); - const { products: [product] } = await vcsDeprecated - ["GET /api/io/_v/api/intelligent-search/product_search/*facets"]({ - ...params, - facets: toPath(facets), - }, { ...STALE, headers: withSegmentCookie(segment) }) - .then((res) => res.json()); + const params = withDefaultParams({ + query, + count: 1, + locale, + simulationBehavior: props.simulationBehavior ?? "default", + }); + const { products: [firstProduct] } = await searchProducts( + ctx, + segment, + params, + toPath(withDefaultFacets([], ctx)), + ); + product = firstProduct; + } // Product not found, return the 404 status code if (!product) { @@ -133,18 +170,20 @@ const loader = async ( let kitItems: VTEXProduct[] = []; if (sku.isKit && sku.kitItems) { + // Kit components are a multi-SKU lookup, which the single-product endpoint + // does not support, so it stays on the product_search pipeline. const params = withDefaultParams({ query: `sku:${sku.kitItems.join(";")}`, count: sku.kitItems.length, simulationBehavior: props.simulationBehavior ?? "default", }); - const result = await vcsDeprecated - ["GET /api/io/_v/api/intelligent-search/product_search/*facets"]({ - ...params, - facets: toPath(facets), - }, { ...STALE, headers: withSegmentCookie(segment) }) - .then((res) => res.json()); + const result = await searchProducts( + ctx, + segment, + params, + toPath(withDefaultFacets([], ctx)), + ); kitItems = result.products; } diff --git a/vtex/loaders/intelligentSearch/productList.ts b/vtex/loaders/intelligentSearch/productList.ts index b55263334..211859a90 100644 --- a/vtex/loaders/intelligentSearch/productList.ts +++ b/vtex/loaders/intelligentSearch/productList.ts @@ -1,8 +1,8 @@ import type { Product } from "../../../commerce/types.ts"; -import { STALE } from "../../../utils/fetch.ts"; import { AppContext } from "../../mod.ts"; import { isFilterParam, + searchProducts, toPath, withDefaultFacets, withDefaultParams, @@ -10,7 +10,6 @@ import { import { getSegmentCacheKeyWithoutUTM, getSegmentFromBag, - withSegmentCookie, } from "../../utils/segment.ts"; import { withIsSimilarTo } from "../../utils/similars.ts"; import { sortProducts, toProduct } from "../../utils/transform.ts"; @@ -218,7 +217,6 @@ const loader = async ( ): Promise => { const props = expandedProps.props ?? (expandedProps as unknown as Props["props"]); - const { vcsDeprecated } = ctx; const { url } = req; const segment = getSegmentFromBag(ctx); const locale = segment?.payload?.cultureInfo ?? @@ -228,12 +226,12 @@ const loader = async ( const params = withDefaultParams({ ...args, locale }); const facets = withDefaultFacets(selectedFacets, ctx); - const { products: vtexProducts } = await vcsDeprecated - ["GET /api/io/_v/api/intelligent-search/product_search/*facets"]({ - ...params, - facets: toPath(facets), - }, { ...STALE, headers: withSegmentCookie(segment) }) - .then((res) => res.json()); + const { products: vtexProducts } = await searchProducts( + ctx, + segment, + params, + toPath(facets), + ); const options = { baseUrl: url, diff --git a/vtex/loaders/intelligentSearch/productListingPage.ts b/vtex/loaders/intelligentSearch/productListingPage.ts index f87064bb2..098e25608 100644 --- a/vtex/loaders/intelligentSearch/productListingPage.ts +++ b/vtex/loaders/intelligentSearch/productListingPage.ts @@ -1,10 +1,11 @@ import type { ProductListingPage } from "../../../commerce/types.ts"; import { parseRange } from "../../../commerce/utils/filters.ts"; -import { STALE } from "../../../utils/fetch.ts"; import sendEvent from "../../actions/analytics/sendEvent.ts"; import { AppContext } from "../../mod.ts"; import { isFilterParam, + searchFacets, + searchProducts, toPath, withDefaultFacets, withDefaultParams, @@ -17,7 +18,6 @@ import { import { getSegmentCacheKeyWithoutUTM, getSegmentFromBag, - withSegmentCookie, } from "../../utils/segment.ts"; import { pageTypesFromUrl } from "../../utils/intelligentSearch.ts"; import { withIsSimilarTo } from "../../utils/similars.ts"; @@ -276,7 +276,6 @@ const loader = async ( req: Request, ctx: AppContext, ): Promise => { - const { vcsDeprecated } = ctx; const { url: baseUrl } = req; const url = new URL(props.pageHref || baseUrl); const segment = getSegmentFromBag(ctx); @@ -315,19 +314,8 @@ const loader = async ( const params = withDefaultParams({ ...searchArgs, page, locale }); // search products on VTEX. Feel free to change any of these parameters const [productsResult, facetsResult] = await Promise.all([ - vcsDeprecated - ["GET /api/io/_v/api/intelligent-search/product_search/*facets"]({ - ...params, - facets: toPath(selected), - }, { - ...STALE, - headers: segment ? withSegmentCookie(segment) : undefined, - }).then((res) => res.json()), - vcsDeprecated["GET /api/io/_v/api/intelligent-search/facets/*facets"]({ - ...params, - facets: toPath(fselected), - }, { ...STALE, headers: segment ? withSegmentCookie(segment) : undefined }) - .then((res) => res.json()), + searchProducts(ctx, segment, params, toPath(selected)), + searchFacets(ctx, segment, params, toPath(fselected)), ]); const currentPageTypes = !props.useCollectionName diff --git a/vtex/loaders/intelligentSearch/suggestions.ts b/vtex/loaders/intelligentSearch/suggestions.ts index 7530f85d6..700264ff3 100644 --- a/vtex/loaders/intelligentSearch/suggestions.ts +++ b/vtex/loaders/intelligentSearch/suggestions.ts @@ -1,15 +1,16 @@ import { Suggestion } from "../../../commerce/types.ts"; -import { STALE } from "../../../utils/fetch.ts"; import { AppContext } from "../../mod.ts"; import { + searchProducts, + searchSuggestions, toPath, + topSearches, withDefaultFacets, withDefaultParams, } from "../../utils/intelligentSearch.ts"; import { getSegmentCacheKeyWithoutUTM, getSegmentFromBag, - withSegmentCookie, } from "../../utils/segment.ts"; import { withIsSimilarTo } from "../../utils/similars.ts"; import { toProduct } from "../../utils/transform.ts"; @@ -44,7 +45,6 @@ const loaders = async ( req: Request, ctx: AppContext, ): Promise => { - const { vcsDeprecated } = ctx; const { url } = req; const { count, query } = props; const segment = getSegmentFromBag(ctx); @@ -52,35 +52,19 @@ const loaders = async ( ctx.defaultSegment?.cultureInfo ?? "pt-BR"; const suggestions = () => - vcsDeprecated["GET /api/io/_v/api/intelligent-search/search_suggestions"]({ - locale, - query: query ?? "", - }, { - // Not adding suggestions to cache since queries are very spread out - // deco: { cache: "stale-while-revalidate" }, - headers: withSegmentCookie(segment), - }).then((res) => res.json()); + searchSuggestions(ctx, segment, { locale, query: query ?? "" }); - const topSearches = () => - vcsDeprecated["GET /api/io/_v/api/intelligent-search/top_searches"]({ - locale, - }, { ...STALE, headers: withSegmentCookie(segment) }) - .then((res) => res.json()); + const getTopSearches = () => topSearches(ctx, segment, { locale }); const productSearch = () => { const facets = withDefaultFacets([], ctx); const params = withDefaultParams({ query, count: count ?? 4, locale }); - return vcsDeprecated - ["GET /api/io/_v/api/intelligent-search/product_search/*facets"]({ - ...params, - facets: toPath(facets), - }, { ...STALE, headers: withSegmentCookie(segment) }) - .then((res) => res.json()); + return searchProducts(ctx, segment, params, toPath(facets)); }; const [{ searches }, { products, recordsFiltered }] = await Promise.all([ - query ? suggestions() : topSearches(), + query ? suggestions() : getTopSearches(), productSearch(), ]); diff --git a/vtex/loaders/intelligentSearch/topsearches.ts b/vtex/loaders/intelligentSearch/topsearches.ts index 94bc087fb..eeb010195 100644 --- a/vtex/loaders/intelligentSearch/topsearches.ts +++ b/vtex/loaders/intelligentSearch/topsearches.ts @@ -2,9 +2,8 @@ import { AppContext } from "../../mod.ts"; import { getSegmentCacheKeyWithoutUTM, getSegmentFromBag, - withSegmentCookie, } from "../../utils/segment.ts"; -import { STALE } from "../../../utils/fetch.ts"; +import { topSearches } from "../../utils/intelligentSearch.ts"; import { Suggestion } from "../../../commerce/types.ts"; /** @@ -20,11 +19,7 @@ export default async function ( const locale = segment?.payload?.cultureInfo ?? ctx.defaultSegment?.cultureInfo ?? "pt-BR"; - return await ctx.vcsDeprecated - ["GET /api/io/_v/api/intelligent-search/top_searches"]({ - locale, - }, { ...STALE, headers: withSegmentCookie(getSegmentFromBag(ctx)) }) - .then((res) => res.json()); + return await topSearches(ctx, segment, { locale }); } export const cache = { diff --git a/vtex/mod.ts b/vtex/mod.ts index c287f0864..e569d2813 100644 --- a/vtex/mod.ts +++ b/vtex/mod.ts @@ -85,6 +85,16 @@ export interface Props { */ platform: "vtex"; + /** + * @title Use Intelligent Search API v1 + * @description Opt in to VTEX's new Intelligent Search API v1 for search, PLP, + * PDP, facets and suggestions. v1 no longer reads the segment cookie (context + * is sent as explicit query params) and uses the dedicated `/products` + * endpoint for PDPs. When off, the legacy Intelligent Search API is used. + * @default false + */ + intelligentSearchV1?: boolean; + advancedConfigs?: { doNotFetchVariantsForRelatedProducts?: boolean; /** diff --git a/vtex/utils/client.ts b/vtex/utils/client.ts index b485045ea..ace7d4b48 100644 --- a/vtex/utils/client.ts +++ b/vtex/utils/client.ts @@ -16,8 +16,10 @@ import { OrderFormOrder, PageType, PortalSuggestion, + Product, ProductSearchResult, SelectableGifts, + SimulationBehavior, SimulationItem, SimulationOrderForm, SPEvent, @@ -25,6 +27,31 @@ import { Suggestion, } from "./types.ts"; +/** + * Context parameters for the Intelligent Search API v1. + * + * Unlike the legacy API, v1 no longer reads the `vtex_segment` cookie: locale, + * sales channel, region and marketing context must be sent explicitly as query + * parameters. + * + * @see https://developers.vtex.com/updates/release-notes/2026-07-08-new-intelligent-search-api-v1 + */ +export interface IntelligentSearchContext { + /** @description BCP 47 language code, e.g. pt-BR */ + locale?: string; + /** @description Sales channel (trade policy) ID */ + sc?: string; + /** @description Region ID for regionalized results */ + regionId?: string; + /** @description Three-letter country code (ISO 3166 ALPHA-3), e.g. BRA */ + country?: string; + utmSource?: string; + utmCampaign?: string; + utmiCampaign?: string; + campaigns?: string; + priceTables?: string; +} + export interface VTEXCommerceStable { "GET /api/vtexid/pub/authentication/start": { searchParams: { @@ -137,6 +164,8 @@ export interface VTEXCommerceStable { }; }; "GET /api/catalog_system/pub/category/tree/:level": { response: Category[] }; + // Legacy Intelligent Search endpoints. Kept alongside the v1 routes so the + // `intelligentSearchV1` app flag can fall back to them. "GET /api/io/_v/api/intelligent-search/search_suggestions": { response: Suggestion; searchParams: { locale: string; query: string }; @@ -169,6 +198,55 @@ export interface VTEXCommerceStable { hideUnavailableItems: boolean; }; }; + "GET /api/intelligent-search/v1/search-suggestions": { + response: Suggestion; + searchParams: { locale: string; query: string }; + }; + "GET /api/intelligent-search/v1/top-searches": { + response: Suggestion; + searchParams: { locale: string }; + }; + "GET /api/intelligent-search/v1/product-search/*facets": { + response: ProductSearchResult; + searchParams: + & { + page: number; + count: number; + query?: string; + sort?: string; + fuzzy?: string; + hideUnavailableItems: boolean; + } + & IntelligentSearchContext; + }; + "GET /api/intelligent-search/v1/facets/*facets": { + response: FacetSearchResult; + searchParams: + & { + page: number; + count: number; + query?: string; + sort?: string; + fuzzy?: string; + hideUnavailableItems: boolean; + } + & IntelligentSearchContext; + }; + // Dedicated single-product lookup introduced by the Intelligent Search API v1, + // replacing the `product_search` + `product:`/`sku:` pipeline for PDPs. + "GET /api/intelligent-search/v1/products": { + response: Product; + searchParams: + & { + /** @description Identifier value to look up, interpreted per `field`. */ + value: string; + /** @description Which identifier `value` represents. Defaults to `id`. */ + field?: "id" | "slug" | "ean" | "sku" | "reference"; + hideUnavailableItems?: boolean; + simulationBehavior?: SimulationBehavior; + } + & IntelligentSearchContext; + }; "GET /api/checkout/changeToAnonymousUser/:orderFormId": { response: OrderForm; diff --git a/vtex/utils/intelligentSearch.ts b/vtex/utils/intelligentSearch.ts index 482b33f7a..af07a633d 100644 --- a/vtex/utils/intelligentSearch.ts +++ b/vtex/utils/intelligentSearch.ts @@ -1,9 +1,17 @@ import { AppContext } from "../mod.ts"; import { STALE } from "../../utils/fetch.ts"; +import { + withSegmentCookie, + withSegmentParams, + type WrappedSegment, +} from "./segment.ts"; import type { + FacetSearchResult, + ProductSearchResult, SelectedFacet, SimulationBehavior, Sort, + Suggestion, } from "../utils/types.ts"; export const SESSION_COOKIE = "vtex_is_session"; @@ -73,6 +81,126 @@ export const withDefaultParams = ({ simulationBehavior, }); +/** + * Whether the store opted in to the VTEX Intelligent Search API v1 (via the + * `intelligentSearchV1` app flag). When off, the legacy Intelligent Search + * endpoints are used. + */ +export const isIntelligentSearchV1 = (ctx: AppContext): boolean => + ctx.intelligentSearchV1 ?? false; + +type DefaultParams = ReturnType; + +/** + * Runs a product search on the Intelligent Search API, picking the v1 or the + * legacy endpoint based on the `intelligentSearchV1` flag. v1 forwards the + * segment context as explicit query params; the legacy API reads it from the + * segment cookie. + */ +export const searchProducts = ( + ctx: AppContext, + segment: WrappedSegment | null | undefined, + params: DefaultParams, + facets: string, +): Promise => { + const { vcsDeprecated } = ctx; + + if (isIntelligentSearchV1(ctx)) { + return vcsDeprecated + ["GET /api/intelligent-search/v1/product-search/*facets"]({ + ...params, + ...withSegmentParams(segment), + facets, + }, STALE).then((res) => res.json()); + } + + return vcsDeprecated + ["GET /api/io/_v/api/intelligent-search/product_search/*facets"]({ + ...params, + facets, + }, { + ...STALE, + headers: segment ? withSegmentCookie(segment) : undefined, + }).then((res) => res.json()); +}; + +/** + * Runs a facets search on the Intelligent Search API (v1 or legacy). + */ +export const searchFacets = ( + ctx: AppContext, + segment: WrappedSegment | null | undefined, + params: DefaultParams, + facets: string, +): Promise => { + const { vcsDeprecated } = ctx; + + if (isIntelligentSearchV1(ctx)) { + return vcsDeprecated["GET /api/intelligent-search/v1/facets/*facets"]({ + ...params, + ...withSegmentParams(segment), + facets, + }, STALE).then((res) => res.json()); + } + + return vcsDeprecated["GET /api/io/_v/api/intelligent-search/facets/*facets"]({ + ...params, + facets, + }, { + ...STALE, + headers: segment ? withSegmentCookie(segment) : undefined, + }).then((res) => res.json()); +}; + +/** + * Fetches search term suggestions (v1 or legacy). v1 only accepts locale/query. + */ +export const searchSuggestions = ( + ctx: AppContext, + segment: WrappedSegment | null | undefined, + { locale, query }: { locale: string; query: string }, +): Promise => { + const { vcsDeprecated } = ctx; + + if (isIntelligentSearchV1(ctx)) { + return vcsDeprecated["GET /api/intelligent-search/v1/search-suggestions"]({ + locale, + query, + }).then((res) => res.json()); + } + + return vcsDeprecated + ["GET /api/io/_v/api/intelligent-search/search_suggestions"]({ + locale, + query, + }, { headers: segment ? withSegmentCookie(segment) : undefined }) + .then((res) => res.json()); +}; + +/** + * Fetches the store's top searches (v1 or legacy). v1 only accepts locale. + */ +export const topSearches = ( + ctx: AppContext, + segment: WrappedSegment | null | undefined, + { locale }: { locale: string }, +): Promise => { + const { vcsDeprecated } = ctx; + + if (isIntelligentSearchV1(ctx)) { + return vcsDeprecated["GET /api/intelligent-search/v1/top-searches"]({ + locale, + }, STALE).then((res) => res.json()); + } + + return vcsDeprecated["GET /api/io/_v/api/intelligent-search/top_searches"]({ + locale, + }, { + ...STALE, + headers: segment ? withSegmentCookie(segment) : undefined, + }).then((res) => res.json()); +}; + const IS_ANONYMOUS = Symbol("segment"); const IS_SESSION = Symbol("segment"); diff --git a/vtex/utils/segment.ts b/vtex/utils/segment.ts index 4afa1ca7a..9416f730f 100644 --- a/vtex/utils/segment.ts +++ b/vtex/utils/segment.ts @@ -213,6 +213,44 @@ export const withSegmentCookie = ( return h; }; +/** + * Builds the Intelligent Search API v1 context query parameters from the segment. + * + * The IS API v1 no longer reads the `vtex_segment` cookie, so sales channel, + * region, locale and marketing context must be forwarded explicitly as query + * parameters. + * + * @see https://developers.vtex.com/updates/release-notes/2026-07-08-new-intelligent-search-api-v1 + */ +export const withSegmentParams = (segment?: WrappedSegment | null) => { + const payload = segment?.payload; + if (!payload) { + return {}; + } + + const { + channel, + regionId, + countryCode, + utm_source, + utm_campaign, + utmi_campaign, + campaigns, + priceTables, + } = payload; + + return { + ...(channel ? { sc: channel } : {}), + ...(regionId ? { regionId } : {}), + ...(countryCode ? { country: countryCode } : {}), + ...(utm_source ? { utmSource: utm_source } : {}), + ...(utm_campaign ? { utmCampaign: utm_campaign } : {}), + ...(utmi_campaign ? { utmiCampaign: utmi_campaign } : {}), + ...(typeof campaigns === "string" ? { campaigns } : {}), + ...(priceTables ? { priceTables } : {}), + }; +}; + export const setSegmentBag = ( cookies: Record, req: Request,