Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1d5f45c
feat: resolve localized product slugs via multilanguage API
hellofanny May 22, 2026
9dc46a8
chore: Renames variables and adjust comment
hellofanny May 25, 2026
38f4e99
chore: Adapts binding selector to redirect to the localized slug
hellofanny May 26, 2026
c5ab38b
chore: Extracts/Creates localization product context
hellofanny May 26, 2026
3935d52
fix: Refactor and uses dataplane endpoint for catalog translated product
hellofanny May 26, 2026
ffe51e7
feat: Adds hrefLang list in the head
hellofanny May 27, 2026
be137ca
chore: adjusts hreflang for default locale
hellofanny May 27, 2026
89d20b8
chore: Refactor bit the hreflang links code
hellofanny May 27, 2026
e391de9
chore: use categories[] array for localized PDP breadcrumb
hellofanny May 28, 2026
3f7f5ff
chore: Uses category name from catalog
hellofanny May 28, 2026
a13e5e7
chore: applies coderabbit suggestions
hellofanny May 28, 2026
41777a3
fix: restore pdp page after 404
hellofanny Jun 11, 2026
b658a7c
feat: Adds by-linkid catalog client methods for collection page resol…
hellofanny Jun 17, 2026
b7a4dab
feat: Replaces pagetype with by-linkid cascade in collectionLoader
hellofanny Jun 17, 2026
2ec3fed
feat: Enables localized plp slugs in LocalizationSelector
hellofanny Jun 18, 2026
5c65c06
fix: Passes locale to ServerCollectionPageQuery (collection metadata)
hellofanny Jun 18, 2026
701150d
fix: Removes account param
hellofanny Jun 18, 2026
57a78f2
fix: Normalizes slugs and use category tree for canonical IS facets
hellofanny Jun 19, 2026
fe084f9
fix: use IS selectedFacets locale from URL, not stale session on navigat
hellofanny Jun 24, 2026
0e45315
chore: Adds tests files for commerce and collection
hellofanny Jun 24, 2026
123411d
fix: Applies coderabbit suggestion on test
hellofanny Jun 24, 2026
cedf63a
fix: Scopes persistOtherLocales to PDP flow only
hellofanny Jun 24, 2026
1cadbd2
fix: adds runtime shape guard for recoverOtherLocales JSON parse
hellofanny Jun 24, 2026
a566061
chore: Removes unnecessary type assertions
hellofanny Jun 24, 2026
4acdc42
chore: Updates tests with byLinkId mocks
hellofanny Jun 25, 2026
9360e5b
fix: use router.locale for IS locale facet instead of URL parsing
hellofanny Jun 25, 2026
911a766
fix: Gates IS locale override behind localization.enabled
hellofanny Jul 1, 2026
42a0a75
fix: Caches category tree request-scoped in collection resolvers
hellofanny Jul 2, 2026
c12e1f8
chore: Extracts typed helpers in collection and product resolvers
hellofanny Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/api/src/__generated__/schema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions packages/api/src/platforms/vtex/clients/commerce/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {
Expand Down Expand Up @@ -107,6 +113,44 @@ export const VtexCommerce = (
{ storeCookies }
),
},
byLinkId: {
category: async (
linkId: string
): Promise<ByLinkIdCategoryResponse[] | null> => {
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<ByLinkIdBrandResponse[] | null> => {
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<ByLinkIdCollectionResponse[] | null> => {
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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. */

Check notice on line 8 in packages/api/src/platforms/vtex/clients/commerce/types/ByLinkId.ts

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/api/src/platforms/vtex/clients/commerce/types/ByLinkId.ts#L8

Complete the task associated to this "TODO" comment.
metaTagDescription: string | null
/** Localized linkIds keyed by locale. Null when no multilanguage entries are registered. */
availableLinkIds: Record<string, string> | 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. */

Check notice on line 20 in packages/api/src/platforms/vtex/clients/commerce/types/ByLinkId.ts

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/api/src/platforms/vtex/clients/commerce/types/ByLinkId.ts#L20

Complete the task associated to this "TODO" comment.
metaTagDescription: string | null
availableLinkIds: Record<string, string> | 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. */

Check notice on line 31 in packages/api/src/platforms/vtex/clients/commerce/types/ByLinkId.ts

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/api/src/platforms/vtex/clients/commerce/types/ByLinkId.ts#L31

Complete the task associated to this "TODO" comment.
metaTagDescription: string | null
availableLinkIds: Record<string, string> | null
}
23 changes: 19 additions & 4 deletions packages/api/src/platforms/vtex/clients/search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/platforms/vtex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
account: string
Expand Down
98 changes: 76 additions & 22 deletions packages/api/src/platforms/vtex/loaders/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CollectionPageType[]> => {
const loader = async (slugs: readonly string[]): Promise<Root[]> => {
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,
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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<string, CollectionPageType>(loader, {
// DataLoader is being used to cache requests, not to batch them
return new DataLoader<string, Root>(loader, {
// DataLoader is used for caching, not batching
batch: false,
})
}
Loading
Loading