diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index ceec3aee1f..bfdb45a4a4 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -950,6 +950,7 @@ export type QueryAllProductsArgs = { export type QueryCollectionArgs = { + locale?: InputMaybe; slug: Scalars['String']['input']; }; @@ -1295,6 +1296,11 @@ export type StoreCollection = { id: Scalars['ID']['output']; /** Collection meta information. Used for search. */ meta: StoreCollectionMeta; + /** + * Localized versions of this collection for all available locales. + * Only populated when localization is enabled. + */ + otherLocales?: Maybe>; /** Meta tag data. */ seo: StoreSeo; /** Corresponding collection URL slug, with which to retrieve this entity. */ @@ -1330,6 +1336,15 @@ export type StoreCollectionFacet = { value: Scalars['String']['output']; }; +/** Localized collection data for a specific locale. */ +export type StoreCollectionLocale = { + __typename?: 'StoreCollectionLocale'; + /** Locale code (e.g. "pt-BR", "it-IT"). */ + locale: Scalars['String']['output']; + /** Localized collection slug (e.g. "vestuario/camisetas"). */ + slug: Scalars['String']['output']; +}; + /** Collection meta information. Used for search. */ export type StoreCollectionMeta = { __typename?: 'StoreCollectionMeta'; diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index fbb17d5c82..5479d47153 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/index.ts @@ -14,6 +14,7 @@ import { type UserOrderCancel, type UserOrderListResult, } from '../../../..' +import { isNotFoundError } from '../../../errors' import type { GraphqlContext } from '../../index' import { getWithAppKeyAndToken } from '../../utils/auth' import type { Channel } from '../../utils/channel' @@ -26,6 +27,11 @@ import { import type { ContractResponse } from './Contract' import type { Address, AddressInput } from './types/Address' import type { Brand } from './types/Brand' +import type { + ByLinkIdBrandResponse, + ByLinkIdCategoryResponse, + ByLinkIdCollectionResponse, +} from './types/ByLinkId' import type { CategoryTree } from './types/CategoryTree' import type { MasterDataResponse } from './types/Newsletter' import type { @@ -107,6 +113,44 @@ export const VtexCommerce = ( { storeCookies } ), }, + byLinkId: { + category: async ( + linkId: string + ): Promise => { + try { + return await fetchAPI( + `${base}/api/catalog_system/pub/category/by-linkid/${encodeURIComponent(linkId)}` + ) + } catch (error) { + if (isNotFoundError(error)) return null + throw error + } + }, + brand: async ( + linkId: string + ): Promise => { + try { + return await fetchAPI( + `${base}/api/catalog_system/pub/brand/by-linkid/${encodeURIComponent(linkId)}` + ) + } catch (error) { + if (isNotFoundError(error)) return null + throw error + } + }, + collection: async ( + linkId: string + ): Promise => { + try { + return await fetchAPI( + `${base}/api/catalog_system/pub/collection/by-linkid/${encodeURIComponent(linkId)}` + ) + } catch (error) { + if (isNotFoundError(error)) return null + throw error + } + }, + }, products: { crossselling: ({ type, diff --git a/packages/api/src/platforms/vtex/clients/commerce/types/ByLinkId.ts b/packages/api/src/platforms/vtex/clients/commerce/types/ByLinkId.ts new file mode 100644 index 0000000000..ce98e382f0 --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/commerce/types/ByLinkId.ts @@ -0,0 +1,34 @@ +export interface ByLinkIdCategoryResponse { + id: number + fatherCategoryId: number | null + name: string + linkId: string + title: string | null + description: string | null + /** SEO meta description — equivalent to pagetype.metaTagDescription. TODO: to be added by Catalog team. */ + metaTagDescription: string | null + /** Localized linkIds keyed by locale. Null when no multilanguage entries are registered. */ + availableLinkIds: Record | null +} + +export interface ByLinkIdBrandResponse { + id: number + name: string + linkId: string + title: string | null + description: string | null + /** SEO meta description — equivalent to pagetype.metaTagDescription. TODO: to be added by Catalog team. */ + metaTagDescription: string | null + availableLinkIds: Record | null +} + +export interface ByLinkIdCollectionResponse { + id: number + name: string + linkId: string | null + title: string | null + description: string | null + /** SEO meta description — equivalent to pagetype.metaTagDescription. TODO: to be added by Catalog team. */ + metaTagDescription: string | null + availableLinkIds: Record | null +} diff --git a/packages/api/src/platforms/vtex/clients/search/index.ts b/packages/api/src/platforms/vtex/clients/search/index.ts index 48f3078bd8..0f4c426e71 100644 --- a/packages/api/src/platforms/vtex/clients/search/index.ts +++ b/packages/api/src/platforms/vtex/clients/search/index.ts @@ -80,10 +80,25 @@ function getRegionIdFromContext(ctx: GraphqlContext): string | undefined { function getSegmentLocale(ctx: GraphqlContext): string { const segment = parseSegmentCookie(ctx.headers?.cookie) - - // Prefer ctx.storage.locale (set from trusted selectedFacets) over the - // vtex_segment cookie, which can lag on hard locale-switch navigation. - return ctx.storage.locale || (segment.cultureInfo as string | undefined) || '' + const cultureInfo = (segment.cultureInfo as string | undefined) || '' + + // Locale flows from the URL into IS in one chain: + // router.locale (Next.js) + // → selectedFacets locale facet (useLocalizedVariables in @faststore/core) + // → ctx.storage.locale (mutateLocaleContext in query.ts) + // → IS locale query param (here) + // + // For localized stores: prefer storage.locale over the vtex_segment cookie. + // The cookie can lag after locale navigation, while storage.locale is derived + // from the trusted selectedFacets locale facet in the same request. + + const isLocalizationEnabled = + (ctx.discoveryConfig as { localization?: { enabled?: boolean } }) + ?.localization?.enabled === true + + return isLocalizationEnabled + ? ctx.storage.locale || cultureInfo + : cultureInfo || ctx.storage.locale || '' } export const IntelligentSearch = ( diff --git a/packages/api/src/platforms/vtex/index.ts b/packages/api/src/platforms/vtex/index.ts index b466a993b2..2513924eb5 100644 --- a/packages/api/src/platforms/vtex/index.ts +++ b/packages/api/src/platforms/vtex/index.ts @@ -37,6 +37,10 @@ export interface GraphqlContext { string, import('./clients/catalog').LocalizedProductEntry > + /** Request-scoped cache for the full category tree (depth 10). Shared between meta and otherLocales resolvers. */ + categoryTree?: import( + './clients/commerce/types/CategoryTree' + ).CategoryTree[] } headers: Record account: string diff --git a/packages/api/src/platforms/vtex/loaders/collection.ts b/packages/api/src/platforms/vtex/loaders/collection.ts index 555bf8938e..e8fbeb05f6 100644 --- a/packages/api/src/platforms/vtex/loaders/collection.ts +++ b/packages/api/src/platforms/vtex/loaders/collection.ts @@ -3,49 +3,103 @@ import pLimit from 'p-limit' import { NotFoundError } from '../../errors' import type { Clients } from '../clients' -import type { CollectionPageType } from '../clients/commerce/types/Portal' +import type { + ByLinkIdBrandResponse, + ByLinkIdCategoryResponse, + ByLinkIdCollectionResponse, +} from '../clients/commerce/types/ByLinkId' -// Limits concurrent requests to 20 so that they don't timeout const CONCURRENT_REQUESTS_MAX = 20 -const collectionPageTypes = new Set([ - 'brand', - 'category', - 'department', - 'subcategory', - 'collection', - 'cluster', -] as const) +export type ByLinkIdCategoryRoot = ByLinkIdCategoryResponse & { + entityType: 'category' + /** + * Full accumulated input slug injected by the loader (e.g. "vestuario/camisetas"). + * Used by slugifyRoot so that meta.selectedFacets builds the correct facet keys + * without relying on root.url (which is absent in the by-linkid response). + */ + slug: string +} + +export type ByLinkIdBrandRoot = ByLinkIdBrandResponse & { + entityType: 'brand' +} + +export type ByLinkIdCollectionRoot = ByLinkIdCollectionResponse & { + entityType: 'collection' +} + +export type Root = + | ByLinkIdCategoryRoot + | ByLinkIdBrandRoot + | ByLinkIdCollectionRoot + +export const isCategory = (root: Root): root is ByLinkIdCategoryRoot => + root.entityType === 'category' -export const isCollectionPageType = (x: any): x is CollectionPageType => - typeof x.pageType === 'string' && - collectionPageTypes.has(x.pageType.toLowerCase()) +export const isBrand = (root: Root): root is ByLinkIdBrandRoot => + root.entityType === 'brand' + +export const isCollection = (root: Root): root is ByLinkIdCollectionRoot => + root.entityType === 'collection' export const getCollectionLoader = (_: Options, clients: Clients) => { const limit = pLimit(CONCURRENT_REQUESTS_MAX) - const loader = async ( - slugs: readonly string[] - ): Promise => { + const loader = async (slugs: readonly string[]): Promise => { return Promise.all( slugs.map((slug: string) => limit(async () => { - const page = await clients.commerce.catalog.portal.pagetype(slug) + // Normalize to lowercase so that merchants who register linkIds with mixed + // casing (allowed by the Catalog multilanguage API) get the same resolution + // as lowercase URLs. The by-linkid API is case-sensitive, while the legacy + // pagetype API was not, so skipping this would be a regression. + const normalizedSlug = slug.toLowerCase() + + // For multi-segment slugs (e.g. "vestuario/camisetas") the entity type is + // determined by the last segment — the leaf category owns the page. + // The full slug is injected into the result for meta.selectedFacets and + // breadcrumb URL construction. + const lastSegment = normalizedSlug.split('/').at(-1)! + + // Step 1: category + const categories = + await clients.commerce.catalog.byLinkId.category(lastSegment) + if (categories !== null && categories.length > 0) { + // When multiple categories share the same linkId at different tree levels + // (e.g. "bolas" under both "esportes" and "infantil"), fatherCategoryId-based + // disambiguation can be added here in a follow-up. For now, take the first match. + return { + ...categories[0], + entityType: 'category' as const, + slug: normalizedSlug, + } + } + + // Step 2: brand (always single-segment) + const brands = + await clients.commerce.catalog.byLinkId.brand(normalizedSlug) + if (brands !== null && brands.length > 0) { + return { ...brands[0], entityType: 'brand' as const } + } - if (isCollectionPageType(page)) { - return page + // Step 3: collection cluster (always single-segment) + const collections = + await clients.commerce.catalog.byLinkId.collection(normalizedSlug) + if (collections !== null && collections.length > 0) { + return { ...collections[0], entityType: 'collection' as const } } throw new NotFoundError( - `Catalog returned ${page.pageType} for slug: ${slug}. This usually happens when there is more than one category with the same name in the same category tree level.` + `No catalog entity found for slug: ${slug}. Cascade exhausted (category → brand → collection).` ) }) ) ) } - return new DataLoader(loader, { - // DataLoader is being used to cache requests, not to batch them + return new DataLoader(loader, { + // DataLoader is used for caching, not batching batch: false, }) } diff --git a/packages/api/src/platforms/vtex/resolvers/collection.ts b/packages/api/src/platforms/vtex/resolvers/collection.ts index 878d03e0d7..bfb120e1b3 100644 --- a/packages/api/src/platforms/vtex/resolvers/collection.ts +++ b/packages/api/src/platforms/vtex/resolvers/collection.ts @@ -1,72 +1,147 @@ -import type { GraphqlResolver } from '..' -import type { Brand } from '../clients/commerce/types/Brand' -import type { CategoryTree } from '../clients/commerce/types/CategoryTree' -import type { CollectionPageType } from '../clients/commerce/types/Portal' -import { isCollectionPageType } from '../loaders/collection' +import type { GraphqlContext, GraphqlResolver } from '..' +import { + isBrand, + isCategory, + isCollection, + type ByLinkIdBrandRoot, + type ByLinkIdCategoryRoot, + type ByLinkIdCollectionRoot, +} from '../loaders/collection' import { slugify } from '../utils/slugify' -export type Root = - | Brand - | (CategoryTree & { level: number }) - | CollectionPageType +interface LocalizationConfig { + enabled?: boolean + defaultLocale?: string + locales?: Record +} + +function getLocalizationConfig(ctx: GraphqlContext): LocalizationConfig { + return ( + (ctx.discoveryConfig as { localization?: LocalizationConfig } | undefined) + ?.localization ?? {} + ) +} -const isBrand = (x: any): x is Brand | CollectionPageType => - x.type === 'brand' || - (isCollectionPageType(x) && x.pageType.toLowerCase() === 'brand') +export type Root = + | ByLinkIdCategoryRoot + | ByLinkIdBrandRoot + | ByLinkIdCollectionRoot -const isCollection = (x: Root): x is CollectionPageType => - isCollectionPageType(x) && x.pageType.toLowerCase() === 'collection' +const slugifyRoot = (root: Root): string => { + if (isCategory(root)) { + // root.slug is the full accumulated input slug (e.g. "vestuario/camisetas"), + // injected by the loader — no URL parsing needed. + return root.slug + } -const slugifyRoot = (root: Root) => { - if (isBrand(root) || isCollection(root)) { - return slugify(root.name) + if (isBrand(root)) { + return root.linkId } - if (isCollectionPageType(root)) { - return new URL(`https://${root.url}`).pathname.slice(1).toLowerCase() + // collection — linkId may be null for clusters not yet registered in multilanguage + return root.linkId ?? slugify(root.name) +} + +/** + * Recursively searches the category tree for the item with the given numeric ID + * and returns the canonical (default-locale) slug extracted from its URL. + * + * The Catalog `by-linkid` endpoint echoes back the queried slug in `linkId` + * when the lookup is performed by a localized (non-canonical) slug. Using the + * tree URL instead guarantees we always get the default-locale slug that IS + * expects for `category-N` selectedFacets and for `otherLocales` hreflang. + */ +function findCanonicalSlug( + tree: Array<{ id: number; url: string; children?: unknown[] }>, + targetId: number +): string | null { + for (const item of tree) { + if (item.id === targetId) { + try { + const segments = new URL(item.url).pathname.split('/').filter(Boolean) + + return segments.at(-1) ?? null + } catch { + return null + } + } + + if (Array.isArray(item.children) && item.children.length > 0) { + const found = findCanonicalSlug( + item.children as Array<{ + id: number + url: string + children?: unknown[] + }>, + targetId + ) + + if (found !== null) return found + } } - return new URL(root.url).pathname.slice(1).toLowerCase() + return null } export const StoreCollection: Record> = { id: ({ id }) => id.toString(), slug: (root) => slugifyRoot(root), - seo: (root) => - isBrand(root) || isCollectionPageType(root) - ? { - title: root.title ?? root.name, - description: root.metaTagDescription, - } - : { - title: root.Title, - description: root.MetaTagDescription, - }, - type: (root) => - isBrand(root) - ? 'Brand' - : isCollectionPageType(root) - ? root.pageType - : root.level === 0 - ? 'Department' - : 'Category', - meta: (root) => { + seo: (root) => ({ + title: root.title ?? root.name, + description: root.metaTagDescription, + }), + type: (root) => { + if (isBrand(root)) return 'Brand' + if (isCollection(root)) return 'Collection' + // Department = root category (no parent); Category = everything else. + // SubCategory distinction (3rd level+) requires recursive parent lookup — deferred. + return root.fatherCategoryId === null ? 'Department' : 'Category' + }, + meta: async (root, _, ctx) => { const slug = slugifyRoot(root) - return isBrand(root) - ? { - selectedFacets: [{ key: 'brand', value: slug }], - } - : isCollection(root) - ? { - selectedFacets: [{ key: 'productclusterids', value: root.id }], - } - : { - selectedFacets: slug.split('/').map((segment, index) => ({ - key: `category-${index + 1}`, - value: segment, - })), - } + if (isBrand(root)) { + return { selectedFacets: [{ key: 'brand', value: slug }] } + } + + if (isCollection(root)) { + return { selectedFacets: [{ key: 'productclusterids', value: root.id }] } + } + + // For categories, IS expects canonical (default-locale) slugs in selectedFacets + // regardless of which locale's slug the URL uses. The by-linkid API echoes the + // queried slug in entity.linkId when called by a localized slug, so we fetch + // the category tree and extract the canonical slug from each item's URL instead. + const segments = slug.split('/').filter(Boolean) + const segmentSlugs = segments.map((_, i) => + segments.slice(0, i + 1).join('/') + ) + + const { + loaders: { collectionLoader }, + clients: { commerce }, + } = ctx + + // Fetch tree and segment entities in parallel; tree is cached request-scoped + // so otherLocales (if also requested) shares the same fetch. + const treePromise = + ctx.storage.categoryTree != null + ? Promise.resolve(ctx.storage.categoryTree) + : commerce.catalog.category.tree(10) + + const [entities, tree] = await Promise.all([ + Promise.all(segmentSlugs.map((s) => collectionLoader.load(s))), + treePromise, + ]) + + ctx.storage.categoryTree ??= tree + + return { + selectedFacets: entities.map((entity, index) => ({ + key: `category-${index + 1}`, + value: findCanonicalSlug(tree, entity.id) ?? entity.linkId, + })), + } }, breadcrumbList: async (root, _, ctx) => { const { @@ -76,36 +151,107 @@ export const StoreCollection: Record> = { const slug = slugifyRoot(root) /** - * Split slug into segments so we fetch all data for - * the breadcrumb. For instance, if we get `/foo/bar` - * we need all metadata for both `/foo` and `/bar` and - * thus we need to fetch pageType for `/foo` and `/bar` + * Split slug into segments so each breadcrumb level gets its own + * by-linkid result. For "vestuario/camisetas" this produces two loader + * calls: one for "vestuario" and one for "vestuario/camisetas". */ - const segments = slug.split('/').filter((segment) => Boolean(segment)) - const slugs = segments.map((__, index) => + const segments = slug.split('/').filter(Boolean) + const slugs = segments.map((_, index) => segments.slice(0, index + 1).join('/') ) - const collections: (CollectionPageType & { - slug: string - })[] = await Promise.all( - slugs.map(async (s) => { - const collection = await collectionLoader.load(s) - return { slug: s, ...collection } - }) + const collections = await Promise.all( + slugs.map((s) => collectionLoader.load(s)) ) return { itemListElement: collections.map((collection, index) => ({ - item: isCollection(collection) - ? `/${collection.slug}` - : new URL( - `https://${(collection as CollectionPageType).url}` - ).pathname.toLowerCase(), + item: `/${slugifyRoot(collection)}`, name: collection.name, position: index + 1, })), numberOfItems: collections.length, } }, + + otherLocales: async (root, _, ctx) => { + const localizationConfig = getLocalizationConfig(ctx) + + if (!localizationConfig.enabled) return null + + const configuredLocales = Object.keys(localizationConfig.locales ?? {}) + + if (configuredLocales.length === 0) return null + + const currentLocale = ctx.storage.locale + const slug = slugifyRoot(root) + const segments = slug.split('/').filter(Boolean) + + if (segments.length === 0) return null + + const { + loaders: { collectionLoader }, + clients: { commerce }, + } = ctx + + // Build per-level slug paths: ["vestuario", "vestuario/camisetas"]. + // The collectionLoader DataLoader cache means any segment already fetched + // by breadcrumbList costs nothing here. + const segmentSlugs = segments.map((_, i) => + segments.slice(0, i + 1).join('/') + ) + + let entities: Root[] + + try { + entities = await Promise.all( + segmentSlugs.map((s) => collectionLoader.load(s)) + ) + } catch { + return null + } + + const defaultLocale = (ctx.discoveryConfig as any)?.localization + ?.defaultLocale + + // For category entities, the by-linkid API echoes the queried slug in + // entity.linkId rather than the canonical default-locale slug. Use the + // category tree (URL field) to resolve the canonical slug for the + // defaultLocale instead. + const tree = isCategory(root) + ? await commerce.catalog.category.tree(10) + : null + + return configuredLocales + .map((configuredLocale) => { + if (configuredLocale === currentLocale) { + // The input slug is already the localized path for the current locale. + return { locale: configuredLocale, slug } + } + + // Build the full path by joining each segment's localized linkId. + // If any segment has no availableLinkIds entry for this locale, omit it + // to keep the hreflang cluster symmetric. + const parts: string[] = [] + + for (const entity of entities) { + const linkId = + entity.availableLinkIds?.[configuredLocale] ?? + (configuredLocale === defaultLocale && + tree !== null && + isCategory(entity) + ? (findCanonicalSlug(tree, entity.id) ?? undefined) + : undefined) + + if (!linkId) return null + + parts.push(linkId) + } + + return parts.length > 0 + ? { locale: configuredLocale, slug: parts.join('/') } + : null + }) + .filter((e): e is { locale: string; slug: string } => e !== null) + }, } diff --git a/packages/api/src/platforms/vtex/resolvers/product.ts b/packages/api/src/platforms/vtex/resolvers/product.ts index ff14a77a30..64c49efca6 100644 --- a/packages/api/src/platforms/vtex/resolvers/product.ts +++ b/packages/api/src/platforms/vtex/resolvers/product.ts @@ -1,5 +1,6 @@ -import type { GraphqlResolver } from '..' +import type { GraphqlContext, GraphqlResolver } from '..' import type { StoreImage, StoreProductImageArgs } from '../../..' +import type { LocalizedProductEntry } from '../clients/catalog' import type { Attachment } from '../clients/commerce/types/OrderForm' import { canonicalFromProduct } from '../utils/canonical' import type { EnhancedCommercialOffer } from '../utils/enhanceCommercialOffer' @@ -36,6 +37,41 @@ function removeTrailingSlashes(path: string) { return path.replace(/^\/+|\/+$/g, '') } +/** + * Returns a cached-or-fetched localized product entry from the Catalog Dataplane. + * The entry is stored in `ctx.storage.productTranslationsCache` so it is shared + * across the `breadcrumbList`, `otherLocales`, and slug-validation resolvers within + * the same request. + */ +async function getLocalizedProductEntry( + ctx: GraphqlContext, + productId: string, + locale: string +): Promise { + const cacheKey = `${productId}:${locale}` + const cached = ctx.storage.productTranslationsCache?.get(cacheKey) + + if (cached) return cached + + try { + const result = await ctx.clients.catalog.getLocalizedProduct( + productId, + locale + ) + const entry: LocalizedProductEntry = { + linkId: result.linkId, + categories: result.categories ?? [], + availableLinkIds: result.availableLinkIds ?? {}, + } + ctx.storage.productTranslationsCache ??= new Map() + ctx.storage.productTranslationsCache.set(cacheKey, entry) + + return entry + } catch { + return null + } +} + /** * Finds the index of the main category tree that matches the given category ID. * This avoids including similar categories in the breadcrumb list. @@ -99,32 +135,7 @@ export const StoreProduct: Record> & { const locale = ctx.storage.locale if (isLocalizationEnabled && locale) { - // productTranslationsCache is request-scoped and shared with the slug and otherLocales - // resolvers — if any of them already called getLocalizedProduct for this product+locale, - // we reuse the result here at zero extra cost. - const cacheKey = `${productId}:${locale}` - let entry = ctx.storage.productTranslationsCache?.get(cacheKey) - - if (!entry) { - try { - const result = await ctx.clients.catalog.getLocalizedProduct( - productId, - locale - ) - // Store both linkId (for the product item URL) and the full categories array - // (for per-level localized slugs). We intentionally keep categories[] rather than - // just the leaf category so we never need to reconstruct the hierarchy via split('/'). - entry = { - linkId: result.linkId, - categories: result.categories ?? [], - availableLinkIds: result.availableLinkIds ?? {}, - } - ctx.storage.productTranslationsCache ??= new Map() - ctx.storage.productTranslationsCache.set(cacheKey, entry) - } catch { - // Catalog Dataplane API unavailable — fall through to IS-based behavior below - } - } + const entry = await getLocalizedProductEntry(ctx, productId, locale) if (entry) { // Extract the category IDs that belong to the main tree (same tree chosen from IS above). @@ -300,26 +311,9 @@ export const StoreProduct: Record> & { // availableLinkIds returns localized slug for every locale, // we fetch for the current locale (reusing the request-scoped cache shared with the slug and // breadcrumb resolvers) and read the full map from the response. - const cacheKey = `${productId}:${locale}` - let entry = ctx.storage.productTranslationsCache?.get(cacheKey) - - if (!entry?.availableLinkIds) { - try { - const result = await ctx.clients.catalog.getLocalizedProduct( - productId, - locale - ) - entry = { - linkId: result.linkId, - categories: result.categories ?? [], - availableLinkIds: result.availableLinkIds ?? {}, - } - ctx.storage.productTranslationsCache ??= new Map() - ctx.storage.productTranslationsCache.set(cacheKey, entry) - } catch { - return null - } - } + const entry = await getLocalizedProductEntry(ctx, productId, locale) + + if (!entry?.availableLinkIds) return null const { availableLinkIds } = entry const { linkText } = root.isVariantOf diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index feddea9448..42d75d5341 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -29,6 +29,10 @@ import type { ProfileAddress } from '../clients/commerce/types/Profile' import type { SearchArgs } from '../clients/search' import type { ProductSearchResult } from '../clients/search/types/ProductSearchResult' import type { GraphqlContext } from '../index' +import type { + ByLinkIdBrandRoot, + ByLinkIdCategoryRoot, +} from '../loaders/collection' import { extractRuleForAuthorization } from '../utils/commercialAuth' import { mutateChannelContext, mutateLocaleContext } from '../utils/contex' import { getAuthCookie, parseJwt } from '../utils/cookies' @@ -42,6 +46,7 @@ import { transformSelectedFacet, } from '../utils/facets' import { isValidSkuId, pickBestSku } from '../utils/sku' +import { slugify } from '../utils/slugify' import { SORT_MAP } from '../utils/sort' import { FACET_CROSS_SELLING_MAP } from './../utils/facets' import { StoreCollection } from './collection' @@ -206,9 +211,13 @@ export const Query = { }, collection: ( _: unknown, - { slug }: QueryCollectionArgs, + { slug, locale }: QueryCollectionArgs, ctx: GraphqlContext ) => { + if (locale) { + mutateLocaleContext(ctx, locale) + } + const { loaders: { collectionLoader }, } = ctx @@ -378,25 +387,47 @@ export const Query = { commerce.catalog.category.tree(), ]) - const categories: Array = [] - const dfs = (node: CategoryTree, level: number) => { - categories.push({ ...node, level }) + // Flatten the category tree. parentId is tracked per node so + // the type resolver can correctly classify Departments (fatherCategoryId: null) + // vs Categories (fatherCategoryId: number). + const categoryRoots: ByLinkIdCategoryRoot[] = [] + const dfs = (node: CategoryTree, parentId: number | null) => { + categoryRoots.push({ + id: node.id, + name: node.name, + fatherCategoryId: parentId, + linkId: slugify(node.name), + title: node.Title, + description: null, + metaTagDescription: node.MetaTagDescription, + availableLinkIds: null, + entityType: 'category' as const, + slug: new URL(node.url).pathname.slice(1).toLowerCase(), + }) for (const child of node.children) { - dfs(child, level + 1) + dfs(child, node.id) } } for (const node of tree) { - dfs(node, 0) + dfs(node, null) } - const collections = [ - ...brands - .filter((brand) => brand.isActive) - .map((x) => ({ ...x, type: 'brand' })), - ...categories, - ] + const brandRoots: ByLinkIdBrandRoot[] = brands + .filter((brand) => brand.isActive) + .map((brand) => ({ + id: brand.id, + name: brand.name, + linkId: slugify(brand.name), + title: brand.title, + description: null, + metaTagDescription: brand.metaTagDescription, + availableLinkIds: null, + entityType: 'brand' as const, + })) + + const collections = [...brandRoots, ...categoryRoots] const validCollections = collections // Nullable slugs may cause one route to override the other diff --git a/packages/api/src/platforms/vtex/typeDefs/collection.graphql b/packages/api/src/platforms/vtex/typeDefs/collection.graphql index 0ce9a25039..90ac218e34 100644 --- a/packages/api/src/platforms/vtex/typeDefs/collection.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/collection.graphql @@ -80,4 +80,23 @@ type StoreCollection { Collection type. """ type: StoreCollectionType! + """ + Localized versions of this collection for all available locales. + Only populated when localization is enabled. + """ + otherLocales: [StoreCollectionLocale!] +} + +""" +Localized collection data for a specific locale. +""" +type StoreCollectionLocale { + """ + Locale code (e.g. "pt-BR", "it-IT"). + """ + locale: String! + """ + Localized collection slug (e.g. "vestuario/camisetas"). + """ + slug: String! } diff --git a/packages/api/src/platforms/vtex/typeDefs/query.graphql b/packages/api/src/platforms/vtex/typeDefs/query.graphql index b363001ed8..961288bf01 100644 --- a/packages/api/src/platforms/vtex/typeDefs/query.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/query.graphql @@ -230,6 +230,12 @@ type Query { Collection slug. """ slug: String! + """ + Locale code (e.g. "pt-BR"). When provided, the resolver sets the request + locale so that localization-aware fields (e.g. otherLocales) return data + for the correct locale instead of the store default. + """ + locale: String ): StoreCollection! @cacheControl(scope: "public", sMaxAge: 300, staleWhileRevalidate: 3600) diff --git a/packages/api/test/integration/__snapshots__/queries.test.ts.snap b/packages/api/test/integration/__snapshots__/queries.test.ts.snap index 162a066367..346d6a6cdb 100644 --- a/packages/api/test/integration/__snapshots__/queries.test.ts.snap +++ b/packages/api/test/integration/__snapshots__/queries.test.ts.snap @@ -8,16 +8,6 @@ exports[`\`allCollections\` query 1`] = ` { "cursor": "0", "node": { - "breadcrumbList": { - "itemListElement": [ - { - "item": "/brand", - "name": "Brand", - "position": 1, - }, - ], - "numberOfItems": 1, - }, "id": "9280", "meta": { "selectedFacets": [ @@ -40,16 +30,6 @@ exports[`\`allCollections\` query 1`] = ` { "cursor": "1", "node": { - "breadcrumbList": { - "itemListElement": [ - { - "item": "/skechers", - "name": "Skechers", - "position": 1, - }, - ], - "numberOfItems": 1, - }, "id": "2000001", "meta": { "selectedFacets": [ @@ -72,16 +52,6 @@ exports[`\`allCollections\` query 1`] = ` { "cursor": "2", "node": { - "breadcrumbList": { - "itemListElement": [ - { - "item": "/acer", - "name": "Acer", - "position": 1, - }, - ], - "numberOfItems": 1, - }, "id": "2000002", "meta": { "selectedFacets": [ @@ -104,16 +74,6 @@ exports[`\`allCollections\` query 1`] = ` { "cursor": "3", "node": { - "breadcrumbList": { - "itemListElement": [ - { - "item": "/irobot", - "name": "iRobot", - "position": 1, - }, - ], - "numberOfItems": 1, - }, "id": "2000003", "meta": { "selectedFacets": [ @@ -136,16 +96,6 @@ exports[`\`allCollections\` query 1`] = ` { "cursor": "4", "node": { - "breadcrumbList": { - "itemListElement": [ - { - "item": "/adidas", - "name": "adidas", - "position": 1, - }, - ], - "numberOfItems": 1, - }, "id": "2000004", "meta": { "selectedFacets": [ @@ -498,27 +448,18 @@ exports[`\`collection\` query 1`] = ` "breadcrumbList": { "itemListElement": [ { - "item": "/office", - "name": "Office", - "position": 1, - }, - { - "item": "/office/desks", + "item": "/desks", "name": "Desks", - "position": 2, + "position": 1, }, ], - "numberOfItems": 2, + "numberOfItems": 1, }, "id": "9295", "meta": { "selectedFacets": [ { "key": "category-1", - "value": "office", - }, - { - "key": "category-2", "value": "desks", }, ], @@ -529,7 +470,7 @@ exports[`\`collection\` query 1`] = ` "title": "Desks", "titleTemplate": "", }, - "slug": "office/desks", + "slug": "desks", "type": "Category", }, }, diff --git a/packages/api/test/integration/queries.test.ts b/packages/api/test/integration/queries.test.ts index 472282256d..c9bbf2d0ac 100644 --- a/packages/api/test/integration/queries.test.ts +++ b/packages/api/test/integration/queries.test.ts @@ -6,11 +6,6 @@ import { AllCollectionsQueryFirst5, catalogBrandListFetch, catalogCategory3Fetch, - catalogPageTypeAcer, - catalogPageTypeAdidas, - catalogPageTypeBrand, - catalogPageTypeIRobot, - catalogPageTypeSkechers, } from '../mocks/AllCollectionsQuery' import { AllProductsQueryFirst5, @@ -18,9 +13,8 @@ import { } from '../mocks/AllProductsQuery' import { CollectionDesksQuery, - pageTypeDesksFetch, - pageTypeOfficeDesksFetch, - pageTypeOfficeFetch, + byLinkIdCategoryDesksFetch, + catalogCategory10Fetch, } from '../mocks/CollectionQuery' import { ProductByIdQuery, pdpFetch } from '../mocks/ProductQuery' import { @@ -113,11 +107,7 @@ beforeEach(() => { test('`collection` query', async () => { const run = await createRunner() - const fetchAPICalls = [ - pageTypeDesksFetch, - pageTypeOfficeFetch, - pageTypeOfficeDesksFetch, - ] + const fetchAPICalls = [byLinkIdCategoryDesksFetch, catalogCategory10Fetch] mockedFetch.mockImplementation((info, init) => pickFetchAPICallResult(info, init, fetchAPICalls) @@ -125,7 +115,7 @@ test('`collection` query', async () => { const response = await run(CollectionDesksQuery) - expect(mockedFetch).toHaveBeenCalledTimes(3) + expect(mockedFetch).toHaveBeenCalledTimes(2) fetchAPICalls.forEach((fetchAPICall) => { expect(mockedFetch).toHaveBeenCalledWith( @@ -163,15 +153,7 @@ test('`product` query', async () => { test('`allCollections` query', async () => { const run = await createRunner() - const fetchAPICalls = [ - catalogBrandListFetch, - catalogCategory3Fetch, - catalogPageTypeSkechers, - catalogPageTypeAdidas, - catalogPageTypeAcer, - catalogPageTypeIRobot, - catalogPageTypeBrand, - ] + const fetchAPICalls = [catalogBrandListFetch, catalogCategory3Fetch] mockedFetch.mockImplementation((info, init) => pickFetchAPICallResult(info, init, fetchAPICalls) @@ -179,7 +161,7 @@ test('`allCollections` query', async () => { const response = await run(AllCollectionsQueryFirst5) - expect(mockedFetch).toHaveBeenCalledTimes(7) + expect(mockedFetch).toHaveBeenCalledTimes(2) fetchAPICalls.forEach((fetchAPICall) => { expect(mockedFetch).toHaveBeenCalledWith( diff --git a/packages/api/test/mocks/AllCollectionsQuery.ts b/packages/api/test/mocks/AllCollectionsQuery.ts index f5d783740a..49c01d5ce5 100644 --- a/packages/api/test/mocks/AllCollectionsQuery.ts +++ b/packages/api/test/mocks/AllCollectionsQuery.ts @@ -7,14 +7,6 @@ export const AllCollectionsQueryFirst5 = `query allCollections { id slug type - breadcrumbList { - itemListElement { - item - name - position - } - numberOfItems - } meta { selectedFacets { key @@ -57,48 +49,3 @@ export const catalogCategory3Fetch = { '[{"id":9282,"name":"Office","hasChildren":true,"url":"http://storeframework.vtexcommercestable.com.br/office","children":[{"id":9295,"name":"Desks","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/office/desks","children":[],"Title":"Desks","MetaTagDescription":"Desks for better productivity"},{"id":9296,"name":"Chairs","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/office/chairs","children":[],"Title":"Chairs","MetaTagDescription":"Comfort chairs"}],"Title":"Office","MetaTagDescription":"For the office and home office"},{"id":9285,"name":"Kitchen and Home Appliances","hasChildren":true,"url":"http://storeframework.vtexcommercestable.com.br/kitchen-and-home-appliances","children":[{"id":9293,"name":"Fridges","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/kitchen-and-home-appliances/fridges","children":[],"Title":"Fridges","MetaTagDescription":"Fridges for the penguin"},{"id":9294,"name":"Appliances","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/kitchen-and-home-appliances/appliances","children":[],"Title":"Appliances","MetaTagDescription":"Appliances for you"}],"Title":"Home Appliances","MetaTagDescription":"Stay home with style"},{"id":9286,"name":"Computer and Software","hasChildren":true,"url":"http://storeframework.vtexcommercestable.com.br/computer-and-software","children":[{"id":9291,"name":"Smartphones","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/computer-and-software/smartphones","children":[],"Title":"Smartphones","MetaTagDescription":"You know what it is"},{"id":9292,"name":"Gadgets","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/computer-and-software/gadgets","children":[],"Title":"Gadgets","MetaTagDescription":"Gadgets for you"}],"Title":"Computer and Software","MetaTagDescription":"Get in touch with tomorrows world today"},{"id":9297,"name":"Technology","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/technology","children":[],"Title":"Technology","MetaTagDescription":"Technology"}]' ), } - -export const catalogPageTypeSkechers = { - info: 'https://storeframework.vtexcommercestable.com.br/api/catalog_system/pub/portal/pagetype/skechers', - init: undefined, - options: { storeCookies: expect.any(Function) }, - result: JSON.parse( - `{"id":"2000001","name":"Skechers","url":"storeframework.vtexcommercestable.com.br/Skechers","title":"Skechers","metaTagDescription":"Sport, casual, work, wide, kids' & performance shoes with style, comfort, innovation, quality & value.","pageType":"Brand"}` - ), -} - -export const catalogPageTypeAdidas = { - info: 'https://storeframework.vtexcommercestable.com.br/api/catalog_system/pub/portal/pagetype/adidas', - init: undefined, - options: { storeCookies: expect.any(Function) }, - result: JSON.parse( - `{"id":"2000004","name":"adidas","url":"storeframework.vtexcommercestable.com.br/adidas","title":"adidas","metaTagDescription":"adidas shoes, clothing and view new collections for adidas Originals, running, football, soccer, training and much more.","pageType":"Brand"}` - ), -} - -export const catalogPageTypeAcer = { - info: 'https://storeframework.vtexcommercestable.com.br/api/catalog_system/pub/portal/pagetype/acer', - init: undefined, - options: { storeCookies: expect.any(Function) }, - result: JSON.parse( - `{"id":"2000002","name":"Acer","url":"storeframework.vtexcommercestable.com.br/Acer","title":"Acer","metaTagDescription":"Acer laptops, desktops as well as servers and storage, personal digital assistance (PDA), peripherals, peripherals and e-business services for government, business, education, and home users.","pageType":"Brand"}` - ), -} - -export const catalogPageTypeIRobot = { - info: 'https://storeframework.vtexcommercestable.com.br/api/catalog_system/pub/portal/pagetype/irobot', - init: undefined, - options: { storeCookies: expect.any(Function) }, - result: JSON.parse( - `{"id":"2000003","name":"iRobot","url":"storeframework.vtexcommercestable.com.br/iRobot","title":"iRobot","metaTagDescription":"iRobot, the leading global consumer robot company, designs and builds robots that empower people to do more both inside and outside of the home.","pageType":"Brand"}` - ), -} - -export const catalogPageTypeBrand = { - info: 'https://storeframework.vtexcommercestable.com.br/api/catalog_system/pub/portal/pagetype/brand', - init: undefined, - options: { storeCookies: expect.any(Function) }, - result: JSON.parse( - `{"id":"9280","name":"Brand","url":"storeframework.vtexcommercestable.com.br/Brand","title":"Brand","metaTagDescription":"Brand","pageType":"Brand"}` - ), -} diff --git a/packages/api/test/mocks/CollectionQuery.ts b/packages/api/test/mocks/CollectionQuery.ts index a4deab3ed6..efec27f26f 100644 --- a/packages/api/test/mocks/CollectionQuery.ts +++ b/packages/api/test/mocks/CollectionQuery.ts @@ -1,4 +1,5 @@ import { expect } from 'vitest' + export const CollectionDesksQuery = `query CollectionQuery { collection(slug: "desks") { id @@ -28,29 +29,29 @@ export const CollectionDesksQuery = `query CollectionQuery { } ` -export const pageTypeDesksFetch = { - info: 'https://storeframework.vtexcommercestable.com.br/api/catalog_system/pub/portal/pagetype/desks', - init: undefined, - options: { storeCookies: expect.any(Function) }, - result: JSON.parse( - '{"id":"9295","name":"Desks","url":"storeframework.vtexcommercestable.com.br/Office/Desks","title":"Desks","metaTagDescription":"Desks for better productivity","pageType":"Category"}' - ), -} - -export const pageTypeOfficeFetch = { - info: 'https://storeframework.vtexcommercestable.com.br/api/catalog_system/pub/portal/pagetype/office', +export const byLinkIdCategoryDesksFetch = { + info: 'https://storeframework.vtexcommercestable.com.br/api/catalog_system/pub/category/by-linkid/desks', init: undefined, - options: { storeCookies: expect.any(Function) }, - result: JSON.parse( - '{"id":"9282","name":"Office","url":"storeframework.vtexcommercestable.com.br/Office","title":"Office","metaTagDescription":"For the office and home office","pageType":"Department"}' - ), + options: undefined, + result: [ + { + id: 9295, + fatherCategoryId: 9282, + name: 'Desks', + linkId: 'desks', + title: 'Desks', + description: null, + metaTagDescription: 'Desks for better productivity', + availableLinkIds: null, + }, + ], } -export const pageTypeOfficeDesksFetch = { - info: 'https://storeframework.vtexcommercestable.com.br/api/catalog_system/pub/portal/pagetype/office/desks', +export const catalogCategory10Fetch = { + info: 'https://storeframework.vtexcommercestable.com.br/api/catalog_system/pub/category/tree/10', init: undefined, options: { storeCookies: expect.any(Function) }, result: JSON.parse( - '{"id":"9295","name":"Desks","url":"storeframework.vtexcommercestable.com.br/Office/Desks","title":"Desks","metaTagDescription":"Desks for better productivity","pageType":"Category"}' + '[{"id":9282,"name":"Office","hasChildren":true,"url":"http://storeframework.vtexcommercestable.com.br/office","children":[{"id":9295,"name":"Desks","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/office/desks","children":[],"Title":"Desks","MetaTagDescription":"Desks for better productivity"},{"id":9296,"name":"Chairs","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/office/chairs","children":[],"Title":"Chairs","MetaTagDescription":"Comfort chairs"}],"Title":"Office","MetaTagDescription":"For the office and home office"},{"id":9285,"name":"Kitchen and Home Appliances","hasChildren":true,"url":"http://storeframework.vtexcommercestable.com.br/kitchen-and-home-appliances","children":[{"id":9293,"name":"Fridges","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/kitchen-and-home-appliances/fridges","children":[],"Title":"Fridges","MetaTagDescription":"Fridges for the penguin"},{"id":9294,"name":"Appliances","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/kitchen-and-home-appliances/appliances","children":[],"Title":"Appliances","MetaTagDescription":"Appliances for you"}],"Title":"Home Appliances","MetaTagDescription":"Stay home with style"},{"id":9286,"name":"Computer and Software","hasChildren":true,"url":"http://storeframework.vtexcommercestable.com.br/computer-and-software","children":[{"id":9291,"name":"Smartphones","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/computer-and-software/smartphones","children":[],"Title":"Smartphones","MetaTagDescription":"You know what it is"},{"id":9292,"name":"Gadgets","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/computer-and-software/gadgets","children":[],"Title":"Gadgets","MetaTagDescription":"Gadgets for you"}],"Title":"Computer and Software","MetaTagDescription":"Get in touch with tomorrows world today"},{"id":9297,"name":"Technology","hasChildren":false,"url":"http://storeframework.vtexcommercestable.com.br/technology","children":[],"Title":"Technology","MetaTagDescription":"Technology"}]' ), } diff --git a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts index 39ea35cfda..2c6de6cb4f 100644 --- a/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts +++ b/packages/api/test/unit/platforms/vtex/clients/commerce.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as clients from '../../../../../src/platforms/vtex/clients' // This should be imported AFTER the '../../../../../src/platforms/vtex/clients' +import { NotFoundError } from '../../../../../src/platforms/errors' import { GraphqlVtexContextFactory } from '../../../../../src/platforms/vtex' const apiOptions = { @@ -235,3 +236,124 @@ describe('VTEX Commerce', () => { }) }) }) + +describe('Catalog byLinkId', () => { + describe('category', () => { + it('calls the correct by-linkid URL and returns the response', async () => { + const mockCategory = [{ id: 1, name: 'Apparel', linkId: 'apparel' }] + fetchAPIMocked.mockResolvedValueOnce(mockCategory) + + const { commerce } = clients.getClients(apiOptions, context) + const result = await commerce.catalog.byLinkId.category('apparel') + + expect(fetchAPIMocked).toHaveBeenCalledTimes(1) + const [url] = fetchAPIMocked.mock.calls[0] + expect(url).toContain( + '/api/catalog_system/pub/category/by-linkid/apparel' + ) + expect(result).toEqual(mockCategory) + }) + + it('URL-encodes special characters in the linkId', async () => { + fetchAPIMocked.mockResolvedValueOnce([]) + + const { commerce } = clients.getClients(apiOptions, context) + // Ampersand is a reserved URL character that encodeURIComponent must encode + await commerce.catalog.byLinkId.category('Computer&Software') + + const [url] = fetchAPIMocked.mock.calls[0] + expect(url).toContain('Computer%26Software') + expect(url).not.toContain('Computer&Software') + }) + + it('returns null when the API responds with 404', async () => { + fetchAPIMocked.mockRejectedValueOnce(new NotFoundError()) + + const { commerce } = clients.getClients(apiOptions, context) + const result = await commerce.catalog.byLinkId.category('nonexistent') + + expect(result).toBeNull() + }) + + it('rethrows non-404 errors', async () => { + fetchAPIMocked.mockRejectedValueOnce(new Error('Network error')) + + const { commerce } = clients.getClients(apiOptions, context) + await expect( + commerce.catalog.byLinkId.category('apparel') + ).rejects.toThrow('Network error') + }) + }) + + describe('brand', () => { + it('calls the correct by-linkid URL and returns the response', async () => { + const mockBrand = [{ id: 10, name: 'Adidas', linkId: 'adidas' }] + fetchAPIMocked.mockResolvedValueOnce(mockBrand) + + const { commerce } = clients.getClients(apiOptions, context) + const result = await commerce.catalog.byLinkId.brand('adidas') + + expect(fetchAPIMocked).toHaveBeenCalledTimes(1) + const [url] = fetchAPIMocked.mock.calls[0] + expect(url).toContain('/api/catalog_system/pub/brand/by-linkid/adidas') + expect(result).toEqual(mockBrand) + }) + + it('returns null when the API responds with 404', async () => { + fetchAPIMocked.mockRejectedValueOnce(new NotFoundError()) + + const { commerce } = clients.getClients(apiOptions, context) + const result = await commerce.catalog.byLinkId.brand('unknown-brand') + + expect(result).toBeNull() + }) + + it('rethrows non-404 errors', async () => { + fetchAPIMocked.mockRejectedValueOnce(new Error('Server error')) + + const { commerce } = clients.getClients(apiOptions, context) + await expect(commerce.catalog.byLinkId.brand('adidas')).rejects.toThrow( + 'Server error' + ) + }) + }) + + describe('collection', () => { + it('calls the correct by-linkid URL and returns the response', async () => { + const mockCollection = [ + { id: 42, name: 'Summer Sale', linkId: 'summer-sale' }, + ] + fetchAPIMocked.mockResolvedValueOnce(mockCollection) + + const { commerce } = clients.getClients(apiOptions, context) + const result = await commerce.catalog.byLinkId.collection('summer-sale') + + expect(fetchAPIMocked).toHaveBeenCalledTimes(1) + const [url] = fetchAPIMocked.mock.calls[0] + expect(url).toContain( + '/api/catalog_system/pub/collection/by-linkid/summer-sale' + ) + expect(result).toEqual(mockCollection) + }) + + it('returns null when the API responds with 404', async () => { + fetchAPIMocked.mockRejectedValueOnce(new NotFoundError()) + + const { commerce } = clients.getClients(apiOptions, context) + const result = await commerce.catalog.byLinkId.collection( + 'nonexistent-collection' + ) + + expect(result).toBeNull() + }) + + it('rethrows non-404 errors', async () => { + fetchAPIMocked.mockRejectedValueOnce(new Error('Timeout')) + + const { commerce } = clients.getClients(apiOptions, context) + await expect( + commerce.catalog.byLinkId.collection('summer-sale') + ).rejects.toThrow('Timeout') + }) + }) +}) diff --git a/packages/api/test/unit/platforms/vtex/clients/search.test.ts b/packages/api/test/unit/platforms/vtex/clients/search.test.ts index 60865fcef1..961752c189 100644 --- a/packages/api/test/unit/platforms/vtex/clients/search.test.ts +++ b/packages/api/test/unit/platforms/vtex/clients/search.test.ts @@ -26,6 +26,7 @@ vi.mock('../../../../../src/platforms/vtex/clients/fetch.ts', () => ({ function makeCtx(overrides: { storageLocale?: string cookie?: string + localizationEnabled?: boolean }): GraphqlContext { return { id: 'test', @@ -43,6 +44,10 @@ function makeCtx(overrides: { }, account: 'storeframework', OTEL: {}, + discoveryConfig: + overrides.localizationEnabled != null + ? { localization: { enabled: overrides.localizationEnabled } } + : undefined, } as unknown as GraphqlContext } @@ -53,7 +58,7 @@ function capturedLocale(): string | null { } describe('IntelligentSearch — getSegmentLocale priority', () => { - it('prefers ctx.storage.locale over vtex_segment cookie cultureInfo', async () => { + it('prefers ctx.storage.locale over vtex_segment cookie when localization is enabled', async () => { // Simulate stale vtex_segment cookie from previous locale (en-US) while the // server-side ctx.storage.locale is already updated to it-IT. const enUSSegment = Buffer.from( @@ -65,6 +70,7 @@ describe('IntelligentSearch — getSegmentLocale priority', () => { const ctx = makeCtx({ storageLocale: 'it-IT', cookie: `vtex_segment=${enUSSegment}`, + localizationEnabled: true, }) const is = IntelligentSearch(searchOptions, ctx) @@ -73,7 +79,43 @@ describe('IntelligentSearch — getSegmentLocale priority', () => { expect(capturedLocale()).toBe('it-IT') }) - it('falls back to vtex_segment cultureInfo when ctx.storage.locale is empty', async () => { + it('ignores ctx.storage.locale and uses vtex_segment cultureInfo when localization is disabled', async () => { + // Non-localized stores: the cookie's cultureInfo is the authoritative source. + const enUSSegment = Buffer.from( + JSON.stringify({ cultureInfo: 'en-US' }) + ).toString('base64') + + fetchAPIMocked.mockResolvedValueOnce({ products: { edges: [] } }) + + const ctx = makeCtx({ + storageLocale: 'it-IT', // should be ignored — localization disabled + cookie: `vtex_segment=${enUSSegment}`, + localizationEnabled: false, + }) + + const is = IntelligentSearch(searchOptions, ctx) + await is.products({ page: 0, count: 1 }) + + expect(capturedLocale()).toBe('en-US') + }) + + it('falls back to storage.locale for non-localized stores when no vtex_segment cookie is set', async () => { + // First visit / no cookie — storage.locale is the safety net. + fetchAPIMocked.mockResolvedValueOnce({ products: { edges: [] } }) + + const ctx = makeCtx({ + storageLocale: 'pt-BR', + cookie: '', + localizationEnabled: false, + }) + + const is = IntelligentSearch(searchOptions, ctx) + await is.products({ page: 0, count: 1 }) + + expect(capturedLocale()).toBe('pt-BR') + }) + + it('falls back to vtex_segment cultureInfo when ctx.storage.locale is empty (localization enabled)', async () => { // base64 JSON: { "cultureInfo": "pt-BR" } const ptBRSegment = Buffer.from( JSON.stringify({ cultureInfo: 'pt-BR' }) @@ -84,6 +126,7 @@ describe('IntelligentSearch — getSegmentLocale priority', () => { const ctx = makeCtx({ storageLocale: '', cookie: `vtex_segment=${ptBRSegment}`, + localizationEnabled: true, }) const is = IntelligentSearch(searchOptions, ctx) @@ -97,7 +140,11 @@ describe('IntelligentSearch — getSegmentLocale priority', () => { // URLSearchParams.get('locale') returns null rather than ''. fetchAPIMocked.mockResolvedValueOnce({ products: { edges: [] } }) - const ctx = makeCtx({ storageLocale: '', cookie: '' }) + const ctx = makeCtx({ + storageLocale: '', + cookie: '', + localizationEnabled: true, + }) const is = IntelligentSearch(searchOptions, ctx) await is.products({ page: 0, count: 1 }) diff --git a/packages/api/test/unit/platforms/vtex/loaders/collection.test.ts b/packages/api/test/unit/platforms/vtex/loaders/collection.test.ts new file mode 100644 index 0000000000..6dcd35c37c --- /dev/null +++ b/packages/api/test/unit/platforms/vtex/loaders/collection.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { NotFoundError } from '../../../../../src/platforms/errors' +import type { Clients } from '../../../../../src/platforms/vtex/clients' +import type { + ByLinkIdBrandResponse, + ByLinkIdCategoryResponse, + ByLinkIdCollectionResponse, +} from '../../../../../src/platforms/vtex/clients/commerce/types/ByLinkId' +import { + getCollectionLoader, + isCategory, +} from '../../../../../src/platforms/vtex/loaders/collection' + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const makeCategory = ( + overrides: Partial = {} +): ByLinkIdCategoryResponse => ({ + id: 1, + name: 'Name', + linkId: 'name', + fatherCategoryId: null, + title: null, + description: null, + metaTagDescription: null, + availableLinkIds: null, + ...overrides, +}) + +const makeBrand = ( + overrides: Partial = {} +): ByLinkIdBrandResponse => ({ + id: 10, + name: 'Brand', + linkId: 'brand', + title: null, + description: null, + metaTagDescription: null, + availableLinkIds: null, + ...overrides, +}) + +const makeCollection = ( + overrides: Partial = {} +): ByLinkIdCollectionResponse => ({ + id: 42, + name: 'Collection', + linkId: 'collection', + title: null, + description: null, + metaTagDescription: null, + availableLinkIds: null, + ...overrides, +}) + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const mockCategory = vi.fn() +const mockBrand = vi.fn() +const mockCollection = vi.fn() + +/** Minimal Clients stub exposing only the catalog.byLinkId surface. */ +function makeClients(): Clients { + return { + commerce: { + catalog: { + byLinkId: { + category: mockCategory, + brand: mockBrand, + collection: mockCollection, + }, + }, + }, + } as unknown as Clients +} + +function makeLoader() { + return getCollectionLoader({} as Options, makeClients()) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('getCollectionLoader', () => { + beforeEach(() => { + mockCategory.mockReset() + mockBrand.mockReset() + mockCollection.mockReset() + }) + + describe('entity type cascade', () => { + it('resolves to category when category by-linkid returns a match', async () => { + mockCategory.mockResolvedValueOnce([ + makeCategory({ id: 1, name: 'Apparel', linkId: 'apparel' }), + ]) + + const result = await makeLoader().load('apparel') + + expect(result.entityType).toBe('category') + expect(mockBrand).not.toHaveBeenCalled() + expect(mockCollection).not.toHaveBeenCalled() + }) + + it('falls through to brand when category returns null', async () => { + mockCategory.mockResolvedValueOnce(null) + mockBrand.mockResolvedValueOnce([ + makeBrand({ id: 10, name: 'Adidas', linkId: 'adidas' }), + ]) + + const result = await makeLoader().load('adidas') + + expect(result.entityType).toBe('brand') + expect(mockCollection).not.toHaveBeenCalled() + }) + + it('falls through to brand when category returns an empty array', async () => { + mockCategory.mockResolvedValueOnce([]) + mockBrand.mockResolvedValueOnce([ + makeBrand({ id: 10, name: 'Adidas', linkId: 'adidas' }), + ]) + + const result = await makeLoader().load('adidas') + + expect(result.entityType).toBe('brand') + }) + + it('falls through to collection when both category and brand return null', async () => { + mockCategory.mockResolvedValueOnce(null) + mockBrand.mockResolvedValueOnce(null) + mockCollection.mockResolvedValueOnce([ + makeCollection({ id: 42, name: 'Summer Sale', linkId: 'summer-sale' }), + ]) + + const result = await makeLoader().load('summer-sale') + + expect(result.entityType).toBe('collection') + }) + + it('falls through to collection when brand returns an empty array', async () => { + mockCategory.mockResolvedValueOnce(null) + mockBrand.mockResolvedValueOnce([]) + mockCollection.mockResolvedValueOnce([ + makeCollection({ id: 42, name: 'Summer Sale', linkId: 'summer-sale' }), + ]) + + const result = await makeLoader().load('summer-sale') + + expect(result.entityType).toBe('collection') + }) + + it('throws NotFoundError when all three steps return null', async () => { + mockCategory.mockResolvedValueOnce(null) + mockBrand.mockResolvedValueOnce(null) + mockCollection.mockResolvedValueOnce(null) + + await expect(makeLoader().load('nonexistent')).rejects.toBeInstanceOf( + NotFoundError + ) + }) + }) + + describe('slug normalization', () => { + it('lowercases a mixed-case slug before hitting the API', async () => { + mockCategory.mockResolvedValueOnce([ + makeCategory({ linkId: 'computer---software' }), + ]) + + await makeLoader().load('Computer---Software') + + expect(mockCategory).toHaveBeenCalledWith('computer---software') + }) + + it('preserves the full slug (lowercased) on the returned category root', async () => { + mockCategory.mockResolvedValueOnce([ + makeCategory({ id: 2, name: 'T-Shirts', linkId: 'camisetas' }), + ]) + + const result = await makeLoader().load('vestuario/Camisetas') + + expect(isCategory(result)).toBe(true) + if (isCategory(result)) { + // The slug field must be the full lowercased path, not just the last segment + expect(result.slug).toBe('vestuario/camisetas') + } + }) + }) + + describe('multi-segment slugs', () => { + it('uses only the last path segment when querying the category API', async () => { + mockCategory.mockResolvedValueOnce([ + makeCategory({ + id: 3, + name: 'T-Shirts', + linkId: 'shirts', + fatherCategoryId: 1, + }), + ]) + + await makeLoader().load('apparel/shirts') + + // Only "shirts" should be sent to the by-linkid endpoint, not the full path + expect(mockCategory).toHaveBeenCalledWith('shirts') + }) + }) +}) diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index aa597acaf6..c1e917a7d6 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -26,10 +26,10 @@ type Documents = { "\n fragment ClientSearchSuggestions on Query {\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n }\n }\n }\n": typeof types.ClientSearchSuggestionsFragmentDoc, "\n fragment ClientShippingSimulation on Query {\n shipping(items: $items, postalCode: $postalCode, country: $country) {\n address {\n city\n }\n }\n }\n": typeof types.ClientShippingSimulationFragmentDoc, "\n fragment ClientTopSearchSuggestions on Query {\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n }\n }\n }\n": typeof types.ClientTopSearchSuggestionsFragmentDoc, - "\n fragment ServerCollectionPage on Query {\n collection(slug: $slug) {\n id\n }\n }\n": typeof types.ServerCollectionPageFragmentDoc, + "\n fragment ServerCollectionPage on Query {\n collection(slug: $slug, locale: $locale) {\n id\n }\n }\n": typeof types.ServerCollectionPageFragmentDoc, "\n fragment ServerProduct on Query {\n product(locator: $locator) {\n id: productID\n }\n }\n": typeof types.ServerProductFragmentDoc, "\n query ServerAccountPageQuery {\n accountProfile {\n name\n }\n }\n": typeof types.ServerAccountPageQueryDocument, - "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n }\n }\n": typeof types.ServerCollectionPageQueryDocument, + "\n query ServerCollectionPageQuery($slug: String!, $locale: String) {\n ...ServerCollectionPage\n collection(slug: $slug, locale: $locale) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n }\n }\n": typeof types.ServerCollectionPageQueryDocument, "\n query ServerProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ServerProduct\n product(locator: $locator) {\n id: productID\n\n seo {\n title\n description\n canonical\n }\n\n brand {\n name\n }\n\n sku\n gtin\n mpn\n name\n description\n releaseDate\n\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n\n image {\n url\n alternateName\n }\n\n offers {\n lowPrice\n highPrice\n lowPriceWithTaxes\n priceCurrency\n offers {\n availability\n price\n priceValidUntil\n priceCurrency\n itemCondition\n seller {\n identifier\n }\n }\n }\n\n isVariantOf {\n productGroupID\n }\n\n otherLocales {\n locale\n slug\n }\n\n ...ProductDetailsFragment_product\n }\n }\n": typeof types.ServerProductQueryDocument, "\n fragment UserOrderItemsFragment on UserOrderItems {\n id\n name\n quantity\n sellingPrice\n unitMultiplier\n measurementUnit\n imageUrl\n detailUrl\n refId\n rewardValue\n }\n": typeof types.UserOrderItemsFragmentFragmentDoc, "\n query ServerOrderDetailsQuery($orderId: String!) {\n userOrder(orderId: $orderId) {\n orderId\n creationDate\n status\n canProcessOrderAuthorization\n statusDescription\n allowCancellation\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n storePreferencesData {\n currencyCode\n }\n clientProfileData {\n firstName\n lastName\n email\n phone\n corporateName\n isCorporate\n }\n customFields {\n type\n id\n fields {\n name\n value\n refId\n }\n }\n deliveryOptionsData {\n deliveryOptions {\n selectedSla\n deliveryChannel\n deliveryCompany\n deliveryWindow {\n startDateUtc\n endDateUtc\n price\n }\n shippingEstimate\n shippingEstimateDate\n friendlyShippingEstimate\n friendlyDeliveryOptionName\n seller\n address {\n addressType\n receiverName\n addressId\n versionId\n entityId\n postalCode\n city\n state\n country\n street\n number\n neighborhood\n complement\n reference\n geoCoordinates\n }\n pickupStoreInfo {\n additionalInfo\n address {\n addressType\n receiverName\n addressId\n versionId\n entityId\n postalCode\n city\n state\n country\n street\n number\n neighborhood\n complement\n reference\n geoCoordinates\n }\n dockId\n friendlyName\n isPickupStore\n }\n quantityOfDifferentItems\n total\n items {\n id\n uniqueId\n name\n quantity\n price\n sellingPrice\n imageUrl\n tax\n taxPriceTagsTotal\n total\n }\n }\n contact {\n email\n phone\n name\n }\n }\n paymentData {\n transactions {\n isActive\n payments {\n id\n paymentSystemName\n value\n installments\n referenceValue\n lastDigits\n url\n group\n tid\n connectorResponses {\n authId\n }\n bankIssuedInvoiceIdentificationNumber\n redemptionCode\n paymentOrigin\n }\n }\n }\n totals {\n id\n name\n value\n }\n shopper {\n firstName\n lastName\n email\n phone\n }\n budgetData {\n budgets {\n id\n name\n balance {\n remaining\n }\n allocations {\n id\n linkedEntity {\n id\n }\n reservations\n }\n }\n }\n }\n accountProfile {\n name\n }\n }\n": typeof types.ServerOrderDetailsQueryDocument, @@ -73,10 +73,10 @@ const documents: Documents = { "\n fragment ClientSearchSuggestions on Query {\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n }\n }\n }\n": types.ClientSearchSuggestionsFragmentDoc, "\n fragment ClientShippingSimulation on Query {\n shipping(items: $items, postalCode: $postalCode, country: $country) {\n address {\n city\n }\n }\n }\n": types.ClientShippingSimulationFragmentDoc, "\n fragment ClientTopSearchSuggestions on Query {\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n }\n }\n }\n": types.ClientTopSearchSuggestionsFragmentDoc, - "\n fragment ServerCollectionPage on Query {\n collection(slug: $slug) {\n id\n }\n }\n": types.ServerCollectionPageFragmentDoc, + "\n fragment ServerCollectionPage on Query {\n collection(slug: $slug, locale: $locale) {\n id\n }\n }\n": types.ServerCollectionPageFragmentDoc, "\n fragment ServerProduct on Query {\n product(locator: $locator) {\n id: productID\n }\n }\n": types.ServerProductFragmentDoc, "\n query ServerAccountPageQuery {\n accountProfile {\n name\n }\n }\n": types.ServerAccountPageQueryDocument, - "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n }\n }\n": types.ServerCollectionPageQueryDocument, + "\n query ServerCollectionPageQuery($slug: String!, $locale: String) {\n ...ServerCollectionPage\n collection(slug: $slug, locale: $locale) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n }\n }\n": types.ServerCollectionPageQueryDocument, "\n query ServerProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ServerProduct\n product(locator: $locator) {\n id: productID\n\n seo {\n title\n description\n canonical\n }\n\n brand {\n name\n }\n\n sku\n gtin\n mpn\n name\n description\n releaseDate\n\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n\n image {\n url\n alternateName\n }\n\n offers {\n lowPrice\n highPrice\n lowPriceWithTaxes\n priceCurrency\n offers {\n availability\n price\n priceValidUntil\n priceCurrency\n itemCondition\n seller {\n identifier\n }\n }\n }\n\n isVariantOf {\n productGroupID\n }\n\n otherLocales {\n locale\n slug\n }\n\n ...ProductDetailsFragment_product\n }\n }\n": types.ServerProductQueryDocument, "\n fragment UserOrderItemsFragment on UserOrderItems {\n id\n name\n quantity\n sellingPrice\n unitMultiplier\n measurementUnit\n imageUrl\n detailUrl\n refId\n rewardValue\n }\n": types.UserOrderItemsFragmentFragmentDoc, "\n query ServerOrderDetailsQuery($orderId: String!) {\n userOrder(orderId: $orderId) {\n orderId\n creationDate\n status\n canProcessOrderAuthorization\n statusDescription\n allowCancellation\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n storePreferencesData {\n currencyCode\n }\n clientProfileData {\n firstName\n lastName\n email\n phone\n corporateName\n isCorporate\n }\n customFields {\n type\n id\n fields {\n name\n value\n refId\n }\n }\n deliveryOptionsData {\n deliveryOptions {\n selectedSla\n deliveryChannel\n deliveryCompany\n deliveryWindow {\n startDateUtc\n endDateUtc\n price\n }\n shippingEstimate\n shippingEstimateDate\n friendlyShippingEstimate\n friendlyDeliveryOptionName\n seller\n address {\n addressType\n receiverName\n addressId\n versionId\n entityId\n postalCode\n city\n state\n country\n street\n number\n neighborhood\n complement\n reference\n geoCoordinates\n }\n pickupStoreInfo {\n additionalInfo\n address {\n addressType\n receiverName\n addressId\n versionId\n entityId\n postalCode\n city\n state\n country\n street\n number\n neighborhood\n complement\n reference\n geoCoordinates\n }\n dockId\n friendlyName\n isPickupStore\n }\n quantityOfDifferentItems\n total\n items {\n id\n uniqueId\n name\n quantity\n price\n sellingPrice\n imageUrl\n tax\n taxPriceTagsTotal\n total\n }\n }\n contact {\n email\n phone\n name\n }\n }\n paymentData {\n transactions {\n isActive\n payments {\n id\n paymentSystemName\n value\n installments\n referenceValue\n lastDigits\n url\n group\n tid\n connectorResponses {\n authId\n }\n bankIssuedInvoiceIdentificationNumber\n redemptionCode\n paymentOrigin\n }\n }\n }\n totals {\n id\n name\n value\n }\n shopper {\n firstName\n lastName\n email\n phone\n }\n budgetData {\n budgets {\n id\n name\n balance {\n remaining\n }\n allocations {\n id\n linkedEntity {\n id\n }\n reservations\n }\n }\n }\n }\n accountProfile {\n name\n }\n }\n": types.ServerOrderDetailsQueryDocument, @@ -156,7 +156,7 @@ export function gql(source: "\n fragment ClientTopSearchSuggestions on Query {\ /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n fragment ServerCollectionPage on Query {\n collection(slug: $slug) {\n id\n }\n }\n"): typeof import('./graphql').ServerCollectionPageFragmentDoc; +export function gql(source: "\n fragment ServerCollectionPage on Query {\n collection(slug: $slug, locale: $locale) {\n id\n }\n }\n"): typeof import('./graphql').ServerCollectionPageFragmentDoc; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -168,7 +168,7 @@ export function gql(source: "\n query ServerAccountPageQuery {\n accountProf /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n }\n }\n"): typeof import('./graphql').ServerCollectionPageQueryDocument; +export function gql(source: "\n query ServerCollectionPageQuery($slug: String!, $locale: String) {\n ...ServerCollectionPage\n collection(slug: $slug, locale: $locale) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n }\n }\n"): typeof import('./graphql').ServerCollectionPageQueryDocument; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index 6af0b064b6..a4728f6dc6 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -919,6 +919,7 @@ export type QueryAllProductsArgs = { export type QueryCollectionArgs = { + locale: InputMaybe; slug: Scalars['String']['input']; }; @@ -1242,6 +1243,11 @@ export type StoreCollection = { id: Scalars['ID']['output']; /** Collection meta information. Used for search. */ meta: StoreCollectionMeta; + /** + * Localized versions of this collection for all available locales. + * Only populated when localization is enabled. + */ + otherLocales: Maybe>; /** Meta tag data. */ seo: StoreSeo; /** Corresponding collection URL slug, with which to retrieve this entity. */ @@ -1274,6 +1280,14 @@ export type StoreCollectionFacet = { value: Scalars['String']['output']; }; +/** Localized collection data for a specific locale. */ +export type StoreCollectionLocale = { + /** Locale code (e.g. "pt-BR", "it-IT"). */ + locale: Scalars['String']['output']; + /** Localized collection slug (e.g. "vestuario/camisetas"). */ + slug: Scalars['String']['output']; +}; + /** Collection meta information. Used for search. */ export type StoreCollectionMeta = { /** List of selected collection facets. */ @@ -2618,10 +2632,11 @@ export type ServerAccountPageQueryQuery = { accountProfile: { name: string | nul export type ServerCollectionPageQueryQueryVariables = Exact<{ slug: Scalars['String']['input']; + locale: InputMaybe; }>; -export type ServerCollectionPageQueryQuery = { collection: { id: string, seo: { title: string, description: string }, breadcrumbList: { itemListElement: Array<{ item: string, name: string, position: number }> }, meta: { selectedFacets: Array<{ key: string, value: string }> } } }; +export type ServerCollectionPageQueryQuery = { collection: { id: string, seo: { title: string, description: string }, breadcrumbList: { itemListElement: Array<{ item: string, name: string, position: number }> }, meta: { selectedFacets: Array<{ key: string, value: string }> }, otherLocales: Array<{ locale: string, slug: string }> | null } }; export type ServerProductQueryQueryVariables = Exact<{ locator: Array | IStoreSelectedFacet; @@ -3292,7 +3307,7 @@ export const ClientTopSearchSuggestionsFragmentDoc = new TypedDocumentString(` `, {"fragmentName":"ClientTopSearchSuggestions"}) as unknown as TypedDocumentString; export const ServerCollectionPageFragmentDoc = new TypedDocumentString(` fragment ServerCollectionPage on Query { - collection(slug: $slug) { + collection(slug: $slug, locale: $locale) { id } } @@ -3375,7 +3390,7 @@ export const SearchEvent_MetadataFragmentDoc = new TypedDocumentString(` } `, {"fragmentName":"SearchEvent_metadata"}) as unknown as TypedDocumentString; export const ServerAccountPageQueryDocument = {"__meta__":{"operationName":"ServerAccountPageQuery","operationHash":"9baae331b75848a310fecb457e8c971ae27897ff"}} as unknown as TypedDocumentString; -export const ServerCollectionPageQueryDocument = {"__meta__":{"operationName":"ServerCollectionPageQuery","operationHash":"4b33c5c07f440dc7489e55619dc2211a13786e72"}} as unknown as TypedDocumentString; +export const ServerCollectionPageQueryDocument = {"__meta__":{"operationName":"ServerCollectionPageQuery","operationHash":"a69f9f19036498952c3892b20dba632cacaa76d4"}} as unknown as TypedDocumentString; export const ServerProductQueryDocument = {"__meta__":{"operationName":"ServerProductQuery","operationHash":"c51aaec5d4ed39e5b8d7d65f460fcd2bc8645346"}} as unknown as TypedDocumentString; export const ServerOrderDetailsQueryDocument = {"__meta__":{"operationName":"ServerOrderDetailsQuery","operationHash":"bdf677bbccce12186a5ef15aebdce46585a99782"}} as unknown as TypedDocumentString; export const ServerListOrdersQueryDocument = {"__meta__":{"operationName":"ServerListOrdersQuery","operationHash":"70d06de1da9c11f10ebde31b66fd74eccd456af5"}} as unknown as TypedDocumentString; diff --git a/packages/core/src/components/templates/ProductListingPage/ProductListingPage.tsx b/packages/core/src/components/templates/ProductListingPage/ProductListingPage.tsx index 7373cddc2e..9b880ef1f3 100644 --- a/packages/core/src/components/templates/ProductListingPage/ProductListingPage.tsx +++ b/packages/core/src/components/templates/ProductListingPage/ProductListingPage.tsx @@ -21,6 +21,7 @@ import type { PLPContentType } from 'src/server/cms/plp' import storeConfig from '../../../../discovery.config' import { faststoreLoader } from 'src/components/ui/Image/loader' +import { LocalizedProductProvider } from 'src/sdk/localization/LocalizedProductContext' import ProductListing from './ProductListing' import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig' @@ -142,43 +143,48 @@ export default function ProductListingPage({ : undefined return ( - - {lcpImageUrl && ( - - - - )} - {/* SEO */} - - - - - + + {lcpImageUrl && ( + + + + )} + {/* SEO */} + + + + + + ) } diff --git a/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx b/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx index 7e3a20e149..f74e549e51 100644 --- a/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx +++ b/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx @@ -43,6 +43,7 @@ const LocalizationButton = ({ const buttonRef = useRef(null) const otherLocales = useLocalizedProduct()?.otherLocales ?? undefined + const urlSuffix = useLocalizedProduct()?.urlSuffix ?? '/p' const { languages, @@ -55,7 +56,7 @@ const LocalizationButton = ({ reset, isSaveEnabled, error, - } = useBindingSelector(otherLocales) + } = useBindingSelector(otherLocales, urlSuffix) const { locale: sessionLocale, currency: sessionCurrency } = useSession() diff --git a/packages/core/src/customizations/src/fragments/ServerCollectionPage.ts b/packages/core/src/customizations/src/fragments/ServerCollectionPage.ts index 9a85e92be1..4bf6df1109 100644 --- a/packages/core/src/customizations/src/fragments/ServerCollectionPage.ts +++ b/packages/core/src/customizations/src/fragments/ServerCollectionPage.ts @@ -2,7 +2,7 @@ import { gql } from '@generated' export const fragment = gql(` fragment ServerCollectionPage on Query { - collection(slug: $slug) { + collection(slug: $slug, locale: $locale) { id } } diff --git a/packages/core/src/pages/[...slug].tsx b/packages/core/src/pages/[...slug].tsx index 8224ea07d4..ce370d4f6b 100644 --- a/packages/core/src/pages/[...slug].tsx +++ b/packages/core/src/pages/[...slug].tsx @@ -86,9 +86,9 @@ function Page({ } const query = gql(` - query ServerCollectionPageQuery($slug: String!) { + query ServerCollectionPageQuery($slug: String!, $locale: String) { ...ServerCollectionPage - collection(slug: $slug) { + collection(slug: $slug, locale: $locale) { seo { title description @@ -106,6 +106,10 @@ const query = gql(` value } } + otherLocales { + locale + slug + } } } `) @@ -170,7 +174,7 @@ export const getStaticProps: GetStaticProps< ServerCollectionPageQueryQueryVariables, ServerCollectionPageQueryQuery >({ - variables: { slug }, + variables: { slug, locale }, operation: query, }), contentService.getPlpContent( diff --git a/packages/core/src/pages/[slug]/p.tsx b/packages/core/src/pages/[slug]/p.tsx index ced2fdc3e7..c109a09579 100644 --- a/packages/core/src/pages/[slug]/p.tsx +++ b/packages/core/src/pages/[slug]/p.tsx @@ -93,18 +93,14 @@ function buildHreflangLinks( ): Array<{ rel: string; hrefLang: string; href: string }> { if (!storeConfig.localization?.enabled || !otherLocales?.length) return [] - const locales = storeConfig.localization.locales as Record + const locales = storeConfig.localization.locales const baseStoreUrl = storeConfig.storeUrl.replace(/\/$/, '') - const defaultLocale = storeConfig.localization.defaultLocale as - | string - | undefined + const defaultLocale = storeConfig.localization.defaultLocale const links: Array<{ rel: string; hrefLang: string; href: string }> = [] for (const { locale, slug } of otherLocales) { - const bindingUrl = locales?.[locale]?.bindings?.[0]?.url as - | string - | undefined - if (bindingUrl) { + const bindingUrl = locales?.[locale]?.bindings?.[0]?.url + if (typeof bindingUrl === 'string' && bindingUrl.length > 0) { links.push({ rel: 'alternate', hrefLang: locale, diff --git a/packages/core/src/sdk/localization/LocalizedProductContext.tsx b/packages/core/src/sdk/localization/LocalizedProductContext.tsx index 8264dfbb63..54141aa5f8 100644 --- a/packages/core/src/sdk/localization/LocalizedProductContext.tsx +++ b/packages/core/src/sdk/localization/LocalizedProductContext.tsx @@ -8,32 +8,43 @@ export interface LocalizedProductLocale { interface LocalizedProductData { /** - * Localized slug entries for all available locales of the current product. - * Null when not on a product page or when localization is disabled. + * Localized slug entries for all available locales of the current page. + * Null when localization is disabled or the page type does not support it. */ otherLocales: LocalizedProductLocale[] | null + /** + * Suffix appended after the localized slug when building the redirect URL. + * Use '/p' for product pages (PDP), '' for collection/PLP pages. + */ + urlSuffix: string } const LocalizedProductContext = createContext(null) interface LocalizedProductProviderProps extends PropsWithChildren { otherLocales: LocalizedProductLocale[] | null | undefined + /** + * Suffix to append to the localized slug when building the redirect URL. + * Defaults to '/p' (product pages). Pass '' for collection/PLP pages. + */ + urlSuffix?: string } /** - * Provides localized product data (e.g. otherLocales) to any component in + * Provides localized page data (otherLocales, urlSuffix) to any component in * the tree — including global components like LocalizationButton that are - * not co-located with the PDP sections. + * not co-located with page sections. * - * Set in p.tsx; returns null outside a product page. + * Set in p.tsx (PDP) and [...slug].tsx (PLP); returns null outside those pages. */ export function LocalizedProductProvider({ otherLocales, + urlSuffix = '/p', children, -}: Readonly) { +}: LocalizedProductProviderProps) { const value = useMemo( - () => ({ otherLocales: otherLocales ?? null }), - [otherLocales] + () => ({ otherLocales: otherLocales ?? null, urlSuffix }), + [otherLocales, urlSuffix] ) return ( diff --git a/packages/core/src/sdk/localization/useBindingSelector.ts b/packages/core/src/sdk/localization/useBindingSelector.ts index 1f9bce3560..a3c51dbb3e 100644 --- a/packages/core/src/sdk/localization/useBindingSelector.ts +++ b/packages/core/src/sdk/localization/useBindingSelector.ts @@ -51,6 +51,21 @@ export function persistOtherLocales( } } +function isLocalizedProductLocaleArray( + value: unknown +): value is LocalizedProductLocale[] { + return ( + Array.isArray(value) && + value.every( + (item) => + typeof item === 'object' && + item !== null && + typeof (item as Record).locale === 'string' && + typeof (item as Record).slug === 'string' + ) + ) +} + /** * Recovers a previously persisted localized slug map for the product referenced * by the current PDP URL. Returns null when not on a PDP or nothing is stored. @@ -66,7 +81,9 @@ export function recoverOtherLocales(): LocalizedProductLocale[] | null { `${OTHER_LOCALES_STORAGE_PREFIX}${skuId}` ) - return raw ? (JSON.parse(raw) as LocalizedProductLocale[]) : null + if (!raw) return null + const parsed: unknown = JSON.parse(raw) + return isLocalizedProductLocaleArray(parsed) ? parsed : null } catch { return null } @@ -125,13 +142,16 @@ export interface UseBindingSelectorReturn { * Hook that provides state and actions for the localization selector. * Manages locale selection, currency filtering, and binding resolution. * - * @param otherLocales - Optional list of localized slugs for the current product. - * When provided (e.g. on PDP), the save action navigates to the localized product - * URL instead of preserving the current page path verbatim. + * @param otherLocales - Optional list of localized slugs for the current page. + * When provided (e.g. on PDP or PLP), the save action navigates to the + * localized page URL instead of preserving the current page path verbatim. + * @param urlSuffix - Suffix appended after the slug when building the redirect URL. + * Use '/p' for product pages (default) and '' for collection/PLP pages. * @returns Object with languages, currencies, selections, and actions */ export function useBindingSelector( - otherLocales?: Array<{ locale: string; slug: string }> | null + otherLocales?: Array<{ locale: string; slug: string }> | null, + urlSuffix = '/p' ): UseBindingSelectorReturn { const { locale: currentLocale, currency: currentCurrency } = useSession() const localizationConfig = storeConfig.localization as LocalizationConfig @@ -162,14 +182,14 @@ export function useBindingSelector( } }, [currentCurrency?.code]) - // Persist the product's localized slugs (when on a PDP) so a later locale - // switch can rebuild the canonical localized URL even from a context-less - // page (e.g. a 404 for a locale where the product is unavailable). + // Persist the product's localized slugs so a later locale switch can rebuild + // the canonical localized URL even from a context-less page (e.g. a 404). + // Only relevant on PDPs — PLP otherLocales are collection slugs, not products. useEffect(() => { - if (otherLocales?.length) { + if (urlSuffix === '/p' && otherLocales?.length) { persistOtherLocales(otherLocales) } - }, [otherLocales]) + }, [otherLocales, urlSuffix]) // Build language options with disambiguation - returns Record const languages = useMemo( @@ -282,11 +302,17 @@ export function useBindingSelector( if (entry) { const baseUrl = binding.url.replace(/\/$/, '') - window.location.href = `${baseUrl}/${entry.slug}/p${window.location.search}${window.location.hash}` + window.location.href = `${baseUrl}/${entry.slug}${urlSuffix}${window.location.search}${window.location.hash}` return } } + // otherLocales is empty/null but we're still on a PDP: strip the stale slug. + if (window.location.pathname.endsWith('/p')) { + window.location.href = binding.url + return + } + // No localized slugs available (not even persisted): preserve the current // page path under the target binding instead of dropping the user on the // locale home. For a default-locale slug this resolves the product; for an @@ -302,6 +328,7 @@ export function useBindingSelector( localizationConfig.locales, localizationConfig.defaultLocale, otherLocales, + urlSuffix, ]) const isSaveEnabled = Boolean(localeCode && currencyCode && !error)