Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 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.

52 changes: 52 additions & 0 deletions packages/api/src/platforms/vtex/clients/catalog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { fetchAPI } from '../fetch'

export interface LocalizedCategoryEntry {
id: number
name: string
/** Slash-separated IDs from root to this node, e.g. "9281/9285". Used to determine depth. */
fullPath: string
/** Slash-separated localized slugs from root to this node, e.g. "apparel/t-shirts". */
fullPathUriName: string
}

export interface LocalizedProductEntry {
linkId: string
/** All categories the product belongs to across all trees, as returned by Catalog Dataplane. */
categories: LocalizedCategoryEntry[]
/** Localized linkIds keyed by locale, covering all available locales in a single response. */
availableLinkIds: Record<string, string>
}

export interface LocalizedProductResponse {
id: number
linkId: string
name: string
/** Leaf category (deepest level) the product is registered under. */
category: LocalizedCategoryEntry | null
/** Full ancestry chain for every category tree the product belongs to. */
categories: LocalizedCategoryEntry[]
/** Localized linkIds keyed by locale, covering all available locales in a single response. */
availableLinkIds: Record<string, string>
}

/**
* Client for the VTEX Catalog Dataplane API.
* Uses Accept-Language header to return locale-specific product data.
*/
export const CatalogDataplane = ({ account, environment }: Options) => {
const base = `https://${account}.${environment}.com.br`

return {
getLocalizedProduct: (
productId: string,
locale: string
): Promise<LocalizedProductResponse> =>
fetchAPI(`${base}/api/catalog-dataplane/product/${productId}`, {
method: 'GET',
headers: {
'Accept-Language': locale,
'Content-Type': 'application/json',
},
}),
}
}
3 changes: 3 additions & 0 deletions packages/api/src/platforms/vtex/clients/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GraphqlContext } from '..'
import { CatalogDataplane } from './catalog'
import { VtexCommerce } from './commerce'
import { IntelligentSearch } from './search'

Expand All @@ -7,9 +8,11 @@ export type Clients = ReturnType<typeof getClients>
export const getClients = (options: Options, ctx: GraphqlContext) => {
const search = IntelligentSearch(options, ctx)
const commerce = VtexCommerce(options, ctx)
const catalog = CatalogDataplane(options)

return {
search,
commerce,
catalog,
}
}
7 changes: 7 additions & 0 deletions packages/api/src/platforms/vtex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,17 @@ export interface GraphqlContext {
flags: FeatureFlags
searchArgs?: Omit<SearchArgs, 'type'>
cookies: Map<string, Record<string, string>>
/** Cached localized product entries keyed by "productId:locale". Shared between slug validation, otherLocales, and breadcrumb. */
productTranslationsCache?: Map<
string,
import('./clients/catalog').LocalizedProductEntry
>
}
headers: Record<string, string>
account: string
OTEL: Record<string, unknown>
/** Discovery config passed from @faststore/core, including localization settings. */
discoveryConfig?: Record<string, unknown>
}

export const GraphqlVtexContextFactory = async (options: Options) => {
Expand Down
169 changes: 159 additions & 10 deletions packages/api/src/platforms/vtex/resolvers/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,101 @@ export const StoreProduct: Record<string, GraphqlResolver<Root>> & {
}),
brand: ({ isVariantOf: { brand } }) => ({ name: brand }),
unitMultiplier: ({ unitMultiplier }) => unitMultiplier,
breadcrumbList: ({
isVariantOf: {
categories,
productName,
linkText,
categoryId,
categoriesIds,
},
itemId,
}) => {
breadcrumbList: async (root, _args, ctx) => {
const {
isVariantOf: {
categories,
productName,
linkText,
categoryId,
categoriesIds,
productId,
},
itemId,
} = root

const mainTreeIndex = findMainTreeIndex(categoriesIds, categoryId)
const mainTree = categories[mainTreeIndex]
const splittedCategories = removeTrailingSlashes(mainTree).split('/')

const isLocalizationEnabled =
(ctx.discoveryConfig as any)?.localization?.enabled === true
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
}
}

if (entry) {
// Extract the category IDs that belong to the main tree (same tree chosen from IS above).
// A product can be registered in multiple trees; Catalog Dataplane returns all of them
// in categories[], so we filter to only the ones matching this tree's IDs.
const mainTreeIds = new Set(
removeTrailingSlashes(categoriesIds[mainTreeIndex])
.split('/')
.filter(Boolean)
)

const localizedCategories = entry.categories
.filter((category) => mainTreeIds.has(category.id.toString()))
.sort(
(a, b) =>
a.fullPath.split('/').length - b.fullPath.split('/').length
)

// Length guard: if Catalog Dataplane returns fewer categories than IS expects
// (e.g. data inconsistency or empty categories), fall through to the IS fallback.
const hasAllBreadcrumbLevels =
localizedCategories.length === splittedCategories.length
if (hasAllBreadcrumbLevels) {
return {
itemListElement: [
// Category items: both name and slug come from Catalog Dataplane, ensuring
// they are always consistent with each other for the requested locale.
...localizedCategories.map((category, index) => ({
name: category.name,
item: `/${category.fullPathUriName}/`,
position: index + 1,
})),
{
name: productName,
item: getPath(entry.linkId, itemId),
position: splittedCategories.length + 1,
},
],
numberOfItems: splittedCategories.length,
}
}
}
}

// Fallback: localization disabled, Catalog Dataplane unavailable, or category count mismatch.
// Builds paths by applying slugify() to the IS category names, which mirrors the behaviour
// of the VTEX Rewriter for default-locale slugs.
return {
itemListElement: [
...splittedCategories.map((name, index) => {
Expand Down Expand Up @@ -198,4 +279,72 @@ export const StoreProduct: Record<string, GraphqlResolver<Root>> & {
advertisement: ({ isVariantOf: { advertisement } }) => advertisement,
deliveryPromiseBadges: ({ isVariantOf: { deliveryPromisesBadges } }) =>
deliveryPromisesBadges,
otherLocales: async (root, _args, ctx) => {
const isLocalizationEnabled =
(ctx.discoveryConfig as any)?.localization?.enabled === true

if (!isLocalizationEnabled) return null

const configuredLocales = Object.keys(
(ctx.discoveryConfig as any)?.localization?.locales ?? {}
)

if (configuredLocales.length === 0) return null

const productId = root.isVariantOf.productId
const itemId = root.itemId
const locale = ctx.storage.locale
const defaultLocale = (ctx.discoveryConfig as any)?.localization
?.defaultLocale

// 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 { availableLinkIds } = entry
const { linkText } = root.isVariantOf

return configuredLocales
.map((configuredLocale) => {
// The default locale always uses the canonical IS linkText: it is always
// present and matches the Query.product `slug.startsWith(linkText)` fast
// path, so the fallback URL resolves cleanly even when the catalog has no
// default-locale entry in availableLinkIds.
if (configuredLocale === defaultLocale) {
return { locale: configuredLocale, slug: getSlug(linkText, itemId) }
}

// Non-default locales only appear when they have a registered localized slug
// in availableLinkIds. Untranslated locales are omitted so they are never
// advertised as hreflang alternates — this keeps the hreflang cluster
// symmetric across all locale variants of the product (every variant emits
// the same set: default + translated locales). The LocalizationSelector
// falls back to the default slug under the target prefix for omitted locales.
const linkId = availableLinkIds[configuredLocale]
return linkId
? { locale: configuredLocale, slug: getSlug(linkId, itemId) }
: null
})
.filter((e): e is { locale: string; slug: string } => e !== null)
},
}
Loading
Loading