diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index d31dee5bce..ceec3aee1f 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -1625,6 +1625,11 @@ export type StoreProduct = { name: Scalars['String']['output']; /** Aggregate offer information. */ offers: StoreAggregateOffer; + /** + * Localized versions of this product for all available locales. + * Only populated when localization is enabled. + */ + otherLocales?: Maybe>; /** Product ID, such as [ISBN](https://www.isbn-international.org/content/what-isbn) or similar global IDs. */ productID: Scalars['String']['output']; /** The product's release date. Formatted using https://en.wikipedia.org/wiki/ISO_8601 */ @@ -1689,6 +1694,15 @@ export type StoreProductGroup = { skuVariants?: Maybe; }; +/** Localized product data for a specific locale. */ +export type StoreProductLocale = { + __typename?: 'StoreProductLocale'; + /** Locale code (e.g. "pt-BR", "it-IT"). */ + locale: Scalars['String']['output']; + /** Localized product slug including the SKU ID suffix (e.g. "adidas-polo-uomo-65"). */ + slug: Scalars['String']['output']; +}; + /** Properties that can be associated with products and products groups. */ export type StorePropertyValue = { __typename?: 'StorePropertyValue'; diff --git a/packages/api/src/platforms/vtex/clients/catalog/index.ts b/packages/api/src/platforms/vtex/clients/catalog/index.ts new file mode 100644 index 0000000000..de0e820232 --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/catalog/index.ts @@ -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 +} + +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 +} + +/** + * 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 => + fetchAPI(`${base}/api/catalog-dataplane/product/${productId}`, { + method: 'GET', + headers: { + 'Accept-Language': locale, + 'Content-Type': 'application/json', + }, + }), + } +} diff --git a/packages/api/src/platforms/vtex/clients/index.ts b/packages/api/src/platforms/vtex/clients/index.ts index 910989d913..3491823dc3 100644 --- a/packages/api/src/platforms/vtex/clients/index.ts +++ b/packages/api/src/platforms/vtex/clients/index.ts @@ -1,4 +1,5 @@ import type { GraphqlContext } from '..' +import { CatalogDataplane } from './catalog' import { VtexCommerce } from './commerce' import { IntelligentSearch } from './search' @@ -7,9 +8,11 @@ export type Clients = ReturnType 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, } } diff --git a/packages/api/src/platforms/vtex/index.ts b/packages/api/src/platforms/vtex/index.ts index 170f690f4a..b466a993b2 100644 --- a/packages/api/src/platforms/vtex/index.ts +++ b/packages/api/src/platforms/vtex/index.ts @@ -32,10 +32,17 @@ export interface GraphqlContext { flags: FeatureFlags searchArgs?: Omit cookies: Map> + /** Cached localized product entries keyed by "productId:locale". Shared between slug validation, otherLocales, and breadcrumb. */ + productTranslationsCache?: Map< + string, + import('./clients/catalog').LocalizedProductEntry + > } headers: Record account: string OTEL: Record + /** Discovery config passed from @faststore/core, including localization settings. */ + discoveryConfig?: Record } export const GraphqlVtexContextFactory = async (options: Options) => { diff --git a/packages/api/src/platforms/vtex/resolvers/product.ts b/packages/api/src/platforms/vtex/resolvers/product.ts index 793b3e992d..ff14a77a30 100644 --- a/packages/api/src/platforms/vtex/resolvers/product.ts +++ b/packages/api/src/platforms/vtex/resolvers/product.ts @@ -77,20 +77,101 @@ export const StoreProduct: Record> & { }), 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) => { @@ -198,4 +279,72 @@ export const StoreProduct: Record> & { 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) + }, } diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 5f8d7d28e2..feddea9448 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -16,8 +16,6 @@ import type { QueryUserOrderArgs, UserOrderFromList, } from '../../../__generated__/schema' -import { getOrderEntryOperation } from './getOrderEntryOperation' -import { getOrderFormItems } from './getOrderFormItems' import { BadRequestError, ForbiddenError, @@ -25,6 +23,7 @@ import { isForbiddenError, isNotFoundError, } from '../../errors' +import type { Clients } from '../clients' import type { CategoryTree } from '../clients/commerce/types/CategoryTree' import type { ProfileAddress } from '../clients/commerce/types/Profile' import type { SearchArgs } from '../clients/search' @@ -46,6 +45,48 @@ import { isValidSkuId, pickBestSku } from '../utils/sku' import { SORT_MAP } from '../utils/sort' import { FACET_CROSS_SELLING_MAP } from './../utils/facets' import { StoreCollection } from './collection' +import { getOrderEntryOperation } from './getOrderEntryOperation' +import { getOrderFormItems } from './getOrderFormItems' + +/** + * Validates that a slug mismatch between IS linkText and the requested slug is + * actually a localized slug match. Fetches the localized product entry from + * Catalog Dataplane (with request-scoped caching) and checks whether the slug + * prefix matches the localized linkId for the current locale. + * + * Returns true if the slug is a valid localized match, false otherwise + * (including when the Dataplane API is unavailable). + */ +async function isLocalizedSlugMatch( + ctx: GraphqlContext, + catalog: Clients['catalog'], + slug: string, + productGroupID: string, + locale: string +): Promise { + const slugPrefix = slug.slice(0, slug.lastIndexOf('-')) + const cacheKey = `${productGroupID}:${locale}` + + try { + let entry = ctx.storage.productTranslationsCache?.get(cacheKey) + + if (!entry) { + const result = await catalog.getLocalizedProduct(productGroupID, locale) + entry = { + linkId: result.linkId, + categories: result.categories ?? [], + availableLinkIds: result.availableLinkIds ?? {}, + } + ctx.storage.productTranslationsCache ??= new Map() + ctx.storage.productTranslationsCache.set(cacheKey, entry) + } + + return entry.linkId === slugPrefix + } catch { + // Catalog Dataplane API unavailable — fall through to error + return false + } +} const INVALID_SKU_ID_ERROR = 'Invalid SkuId' const SLUG_MISMATCH_ERROR = @@ -79,7 +120,7 @@ export const Query = { const { loaders: { skuLoader }, - clients: { commerce, search }, + clients: { commerce, search, catalog }, } = ctx try { @@ -96,13 +137,36 @@ export const Query = { * * In some cases, the slug has a valid skuId for a different * product. This condition makes sure that the fetched sku - * is the one we actually asked for + * is the one we actually asked for. + * + * When localization is enabled, the slug prefix may be a localized + * LinkId that differs from the IS linkText (always in the default locale). + * In that case we validate against the Catalog Dataplane API before + * rejecting the slug. * */ if ( slug && sku.isVariantOf.linkText && !slug.startsWith(sku.isVariantOf.linkText) ) { + const isLocalizationEnabled = + (ctx.discoveryConfig as any)?.localization?.enabled === true + + if ( + isLocalizationEnabled && + locale && + isValidSkuId(slug.split('-').pop() ?? '') && + (await isLocalizedSlugMatch( + ctx, + catalog, + slug, + sku.isVariantOf.productId, + locale + )) + ) { + return sku + } + throw new Error( `Slug was set but the fetched sku does not satisfy the slug condition. slug: ${slug}, linkText: ${sku.isVariantOf.linkText}` ) diff --git a/packages/api/src/platforms/vtex/typeDefs/product.graphql b/packages/api/src/platforms/vtex/typeDefs/product.graphql index 32880e7b86..409cc8dfef 100644 --- a/packages/api/src/platforms/vtex/typeDefs/product.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/product.graphql @@ -94,6 +94,25 @@ type StoreProduct { Delivery Promise product's badge. """ deliveryPromiseBadges: [DeliveryPromiseBadge] + """ + Localized versions of this product for all available locales. + Only populated when localization is enabled. + """ + otherLocales: [StoreProductLocale!] +} + +""" +Localized product data for a specific locale. +""" +type StoreProductLocale { + """ + Locale code (e.g. "pt-BR", "it-IT"). + """ + locale: String! + """ + Localized product slug including the SKU ID suffix (e.g. "adidas-polo-uomo-65"). + """ + slug: String! } type SkuSpecification { diff --git a/packages/api/test/unit/platforms/vtex/clients/catalog.test.ts b/packages/api/test/unit/platforms/vtex/clients/catalog.test.ts new file mode 100644 index 0000000000..332b086c77 --- /dev/null +++ b/packages/api/test/unit/platforms/vtex/clients/catalog.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { CatalogDataplane } from '../../../../../src/platforms/vtex/clients/catalog' + +const catalogOptions = { + platform: 'vtex', + account: 'storeframework', + environment: 'vtexcommercestable', +} as Options + +const fetchAPIMocked = vi.fn() + +beforeEach(() => { + fetchAPIMocked.mockClear() +}) + +vi.mock('../../../../../src/platforms/vtex/clients/fetch.ts', () => ({ + fetchAPI: async (info: RequestInfo, init?: RequestInit) => + fetchAPIMocked(info, init), +})) + +describe('Catalog Dataplane', () => { + describe('getLocalizedProduct', () => { + it('builds the URL from the configured account and environment', async () => { + fetchAPIMocked.mockResolvedValueOnce({}) + + const catalog = CatalogDataplane(catalogOptions) + await catalog.getLocalizedProduct('123', 'en-US') + + const [url] = fetchAPIMocked.mock.calls[0] + expect(url).toBe( + 'https://storeframework.vtexcommercestable.com.br/api/catalog-dataplane/product/123' + ) + }) + + it('passes the locale in the Accept-Language header', async () => { + fetchAPIMocked.mockResolvedValueOnce({}) + + const catalog = CatalogDataplane(catalogOptions) + await catalog.getLocalizedProduct('123', 'pt-BR') + + const [, init] = fetchAPIMocked.mock.calls[0] + expect(init.headers['Accept-Language']).toBe('pt-BR') + }) + + it('propagates errors thrown by fetchAPI', async () => { + fetchAPIMocked.mockRejectedValueOnce(new Error('Network error')) + + const catalog = CatalogDataplane(catalogOptions) + await expect(catalog.getLocalizedProduct('123', 'en-US')).rejects.toThrow( + 'Network error' + ) + }) + }) +}) diff --git a/packages/api/test/unit/platforms/vtex/resolvers/product.test.ts b/packages/api/test/unit/platforms/vtex/resolvers/product.test.ts new file mode 100644 index 0000000000..e67cc80ab0 --- /dev/null +++ b/packages/api/test/unit/platforms/vtex/resolvers/product.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it, vi } from 'vitest' + +import { StoreProduct } from '../../../../../src/platforms/vtex/resolvers/product' + +// Minimal root object that satisfies the fields used by breadcrumbList and otherLocales. +function makeRoot({ + productId = 'prod1', + linkText = 'blue-shirt', + // IS category name paths — each entry is the full slash-separated path for one tree, + // e.g. "Apparel/T-Shirts" means the product is in T-Shirts under Apparel. + categories = ['Apparel/T-Shirts'], + // IS category ID trees — each entry is a slash-separated path like "/1/2/". + categoriesIds = ['/1/2/'], + categoryId = '2', + productName = 'Blue Shirt', + itemId = '100', +}: { + productId?: string + linkText?: string + categories?: string[] + categoriesIds?: string[] + categoryId?: string + productName?: string + itemId?: string +} = {}) { + return { + itemId, + name: null, + isVariantOf: { + productId, + productName, + linkText, + categories, + categoriesIds, + categoryId, + brand: '', + description: '', + productTitle: '', + metaTagDescription: '', + manufacturerCode: null, + skuSpecifications: [], + specificationGroups: [], + releaseDate: null, + advertisement: null, + deliveryPromisesBadges: [], + }, + sellers: [], + images: [], + variations: [], + attributes: [], + attachmentsValues: [], + unitMultiplier: 1, + } as any +} + +// Minimal context object for resolver tests. +function makeCtx({ + localizationEnabled = false, + locale = 'en-US', + locales = {}, + defaultLocale = 'en-US', + getLocalizedProduct = vi.fn(), + cache = undefined, +}: { + localizationEnabled?: boolean + locale?: string + locales?: Record + defaultLocale?: string + getLocalizedProduct?: (...args: any[]) => Promise + cache?: Map +} = {}) { + return { + discoveryConfig: { + localization: localizationEnabled + ? { enabled: true, locales, defaultLocale } + : undefined, + }, + storage: { + locale, + productTranslationsCache: cache, + }, + clients: { + catalog: { getLocalizedProduct }, + }, + } as any +} + +describe('StoreProduct', () => { + describe('breadcrumbList', () => { + it('falls back to IS-based slugs when localization is disabled', async () => { + const root = makeRoot() + const ctx = makeCtx() + + const result = await (StoreProduct.breadcrumbList as any)(root, {}, ctx) + + expect(result.numberOfItems).toBe(2) + expect(result.itemListElement).toHaveLength(3) + expect(result.itemListElement[0]).toMatchObject({ + name: 'Apparel', + item: '/apparel/', + position: 1, + }) + expect(result.itemListElement[1]).toMatchObject({ + name: 'T-Shirts', + item: '/apparel/t-shirts/', + position: 2, + }) + expect(result.itemListElement[2]).toMatchObject({ + name: 'Blue Shirt', + item: '/blue-shirt-100/p', + position: 3, + }) + }) + + it('returns localized breadcrumb when Catalog Dataplane returns matching categories', async () => { + const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ + linkId: 'camisa-azul', + categories: [ + { + id: 1, + name: 'Vestuario', + fullPath: '1', + fullPathUriName: 'vestuario', + }, + { + id: 2, + name: 'Camisetas', + fullPath: '1/2', + fullPathUriName: 'vestuario/camisetas', + }, + ], + availableLinkIds: { 'pt-BR': 'camisa-azul' }, + }) + + const root = makeRoot() + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + getLocalizedProduct, + }) + + const result = await (StoreProduct.breadcrumbList as any)(root, {}, ctx) + + expect(result.itemListElement[0]).toMatchObject({ + name: 'Vestuario', + item: '/vestuario/', + position: 1, + }) + expect(result.itemListElement[1]).toMatchObject({ + name: 'Camisetas', + item: '/vestuario/camisetas/', + position: 2, + }) + // Product item URL uses the localized linkId from Dataplane. + expect(result.itemListElement[2]).toMatchObject({ + name: 'Blue Shirt', + item: '/camisa-azul-100/p', + position: 3, + }) + }) + + it('falls back to IS slugs when Dataplane returns fewer categories than IS expects', async () => { + // Dataplane returns only the leaf category; IS expects 2 (Apparel + T-Shirts). + // hasAllBreadcrumbLevels = false → falls through to the IS fallback. + const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ + linkId: 'camisa-azul', + categories: [ + { + id: 2, + name: 'Camisetas', + fullPath: '1/2', + fullPathUriName: 'vestuario/camisetas', + }, + ], + availableLinkIds: {}, + }) + + const root = makeRoot() + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + getLocalizedProduct, + }) + + const result = await (StoreProduct.breadcrumbList as any)(root, {}, ctx) + + expect(result.itemListElement[0]).toMatchObject({ item: '/apparel/' }) + expect(result.itemListElement[1]).toMatchObject({ + item: '/apparel/t-shirts/', + }) + expect(result.itemListElement[2]).toMatchObject({ + item: '/blue-shirt-100/p', + }) + }) + + it('falls back to IS slugs when Dataplane call throws', async () => { + const getLocalizedProduct = vi + .fn() + .mockRejectedValueOnce(new Error('Dataplane unavailable')) + + const root = makeRoot() + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + getLocalizedProduct, + }) + + const result = await (StoreProduct.breadcrumbList as any)(root, {}, ctx) + + expect(result.itemListElement[0]).toMatchObject({ item: '/apparel/' }) + expect(result.itemListElement[2]).toMatchObject({ + item: '/blue-shirt-100/p', + }) + }) + + it('reuses a cached entry and skips the API call', async () => { + const cachedEntry = { + linkId: 'camisa-azul', + categories: [ + { + id: 1, + name: 'Vestuario', + fullPath: '1', + fullPathUriName: 'vestuario', + }, + { + id: 2, + name: 'Camisetas', + fullPath: '1/2', + fullPathUriName: 'vestuario/camisetas', + }, + ], + availableLinkIds: {}, + } + // Cache key is `${productId}:${locale}` → "prod1:pt-BR" + const cache = new Map([['prod1:pt-BR', cachedEntry]]) + const getLocalizedProduct = vi.fn() + + const root = makeRoot() + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + getLocalizedProduct, + cache, + }) + + await (StoreProduct.breadcrumbList as any)(root, {}, ctx) + + expect(getLocalizedProduct).not.toHaveBeenCalled() + }) + }) + + describe('otherLocales', () => { + it('returns null when localization is disabled', async () => { + const result = await (StoreProduct.otherLocales as any)( + makeRoot(), + {}, + makeCtx() + ) + + expect(result).toBeNull() + }) + + it('returns null when no locales are configured', async () => { + const result = await (StoreProduct.otherLocales as any)( + makeRoot(), + {}, + makeCtx({ localizationEnabled: true, locales: {} }) + ) + + expect(result).toBeNull() + }) + + it('returns localized slugs for configured locales', async () => { + const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ + linkId: 'blue-shirt', + categories: [], + availableLinkIds: { 'pt-BR': 'camisa-azul' }, + }) + + const root = makeRoot() + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'en-US', + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct, + }) + + const result = await (StoreProduct.otherLocales as any)(root, {}, ctx) + + // Default locale always uses the canonical IS linkText. + expect(result).toContainEqual({ locale: 'en-US', slug: 'blue-shirt-100' }) + // Non-default locale uses the translated linkId from availableLinkIds. + expect(result).toContainEqual({ + locale: 'pt-BR', + slug: 'camisa-azul-100', + }) + }) + + it('omits non-default locales with no entry in availableLinkIds', async () => { + const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ + linkId: 'blue-shirt', + categories: [], + availableLinkIds: {}, // pt-BR not translated + }) + + const root = makeRoot() + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'en-US', + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct, + }) + + const result = await (StoreProduct.otherLocales as any)(root, {}, ctx) + + expect(result).toContainEqual({ locale: 'en-US', slug: 'blue-shirt-100' }) + expect(result?.find((e: any) => e.locale === 'pt-BR')).toBeUndefined() + }) + + it('returns null when the Dataplane API throws', async () => { + const getLocalizedProduct = vi + .fn() + .mockRejectedValueOnce(new Error('API error')) + + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'en-US', + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct, + }) + + const result = await (StoreProduct.otherLocales as any)( + makeRoot(), + {}, + ctx + ) + + expect(result).toBeNull() + }) + + it('reuses a cached entry with availableLinkIds and skips the API call', async () => { + const cachedEntry = { + linkId: 'blue-shirt', + categories: [], + availableLinkIds: { 'pt-BR': 'camisa-azul' }, + } + // Cache key is `${productId}:${locale}` → "prod1:en-US" + const cache = new Map([['prod1:en-US', cachedEntry]]) + const getLocalizedProduct = vi.fn() + + const root = makeRoot() + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'en-US', + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct, + cache, + }) + + await (StoreProduct.otherLocales as any)(root, {}, ctx) + + expect(getLocalizedProduct).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index 2b4a413e0b..aa597acaf6 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -30,7 +30,7 @@ type Documents = { "\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 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 ...ProductDetailsFragment_product\n }\n }\n": typeof types.ServerProductQueryDocument, + "\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, "\n query ServerListOrdersQuery ($page: Int,$perPage: Int, $status: [String], $dateInitial: String, $dateFinal: String, $text: String, $clientEmail: String, $pendingMyApproval: Boolean) {\n listUserOrders (page: $page, perPage: $perPage, status: $status, dateInitial: $dateInitial, dateFinal: $dateFinal, text: $text, clientEmail: $clientEmail, pendingMyApproval: $pendingMyApproval) {\n list {\n orderId\n creationDate\n clientName\n items {\n seller\n quantity\n description\n ean\n refId\n id\n productId\n sellingPrice\n price\n }\n totalValue\n status\n statusDescription\n ShippingEstimatedDate\n currencyCode\n customFields {\n type\n value\n }\n }\n paging {\n total\n pages\n currentPage\n perPage\n }\n }\n accountProfile {\n name\n }\n }\n": typeof types.ServerListOrdersQueryDocument, @@ -77,7 +77,7 @@ const documents: Documents = { "\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 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 ...ProductDetailsFragment_product\n }\n }\n": types.ServerProductQueryDocument, + "\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, "\n query ServerListOrdersQuery ($page: Int,$perPage: Int, $status: [String], $dateInitial: String, $dateFinal: String, $text: String, $clientEmail: String, $pendingMyApproval: Boolean) {\n listUserOrders (page: $page, perPage: $perPage, status: $status, dateInitial: $dateInitial, dateFinal: $dateFinal, text: $text, clientEmail: $clientEmail, pendingMyApproval: $pendingMyApproval) {\n list {\n orderId\n creationDate\n clientName\n items {\n seller\n quantity\n description\n ean\n refId\n id\n productId\n sellingPrice\n price\n }\n totalValue\n status\n statusDescription\n ShippingEstimatedDate\n currencyCode\n customFields {\n type\n value\n }\n }\n paging {\n total\n pages\n currentPage\n perPage\n }\n }\n accountProfile {\n name\n }\n }\n": types.ServerListOrdersQueryDocument, @@ -172,7 +172,7 @@ export function gql(source: "\n query ServerCollectionPageQuery($slug: String!) /** * 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 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 ...ProductDetailsFragment_product\n }\n }\n"): typeof import('./graphql').ServerProductQueryDocument; +export function gql(source: "\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 import('./graphql').ServerProductQueryDocument; /** * 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 e1aaee5da1..6af0b064b6 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -358,7 +358,7 @@ export type IGeoCoordinates = { export type IOrderEntryOperation = { objectKey: Scalars['String']['input']; - orderFormId?: InputMaybe; + orderFormId: InputMaybe; sessionToken: InputMaybe; }; @@ -1544,6 +1544,11 @@ export type StoreProduct = { name: Scalars['String']['output']; /** Aggregate offer information. */ offers: StoreAggregateOffer; + /** + * Localized versions of this product for all available locales. + * Only populated when localization is enabled. + */ + otherLocales: Maybe>; /** Product ID, such as [ISBN](https://www.isbn-international.org/content/what-isbn) or similar global IDs. */ productID: Scalars['String']['output']; /** The product's release date. Formatted using https://en.wikipedia.org/wiki/ISO_8601 */ @@ -1605,6 +1610,14 @@ export type StoreProductGroup = { skuVariants: Maybe; }; +/** Localized product data for a specific locale. */ +export type StoreProductLocale = { + /** Locale code (e.g. "pt-BR", "it-IT"). */ + locale: Scalars['String']['output']; + /** Localized product slug including the SKU ID suffix (e.g. "adidas-polo-uomo-65"). */ + slug: Scalars['String']['output']; +}; + /** Properties that can be associated with products and products groups. */ export type StorePropertyValue = { /** Property name. */ @@ -2615,7 +2628,7 @@ export type ServerProductQueryQueryVariables = Exact<{ }>; -export type ServerProductQueryQuery = { product: { sku: string, gtin: string, mpn: string, name: string, description: string, releaseDate: string, unitMultiplier: number | null, id: string, seo: { title: string, description: string, canonical: string }, brand: { name: string }, breadcrumbList: { itemListElement: Array<{ item: string, name: string, position: number }> }, image: Array<{ url: string, alternateName: string }>, offers: { lowPrice: number, highPrice: number, lowPriceWithTaxes: number, priceCurrency: string, offers: Array<{ availability: string, price: number, priceValidUntil: string, priceCurrency: string, itemCondition: string, priceWithTaxes: number, listPrice: number, listPriceWithTaxes: number, quantity: number, seller: { identifier: string } }> }, isVariantOf: { name: string, productGroupID: string, skuVariants: { activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }> } }; +export type ServerProductQueryQuery = { product: { sku: string, gtin: string, mpn: string, name: string, description: string, releaseDate: string, unitMultiplier: number | null, id: string, seo: { title: string, description: string, canonical: string }, brand: { name: string }, breadcrumbList: { itemListElement: Array<{ item: string, name: string, position: number }> }, image: Array<{ url: string, alternateName: string }>, offers: { lowPrice: number, highPrice: number, lowPriceWithTaxes: number, priceCurrency: string, offers: Array<{ availability: string, price: number, priceValidUntil: string, priceCurrency: string, itemCondition: string, priceWithTaxes: number, listPrice: number, listPriceWithTaxes: number, quantity: number, seller: { identifier: string } }> }, isVariantOf: { name: string, productGroupID: string, skuVariants: { activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, otherLocales: Array<{ locale: string, slug: string }> | null, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }> } }; export type UserOrderItemsFragmentFragment = { id: string | null, name: string | null, quantity: number | null, sellingPrice: number | null, unitMultiplier: number | null, measurementUnit: string | null, imageUrl: string | null, detailUrl: string | null, refId: string | null, rewardValue: number | null }; @@ -2714,7 +2727,7 @@ export type OrderEntryOperationQueryQueryVariables = Exact<{ }>; -export type OrderEntryOperationQueryQuery = { orderEntryOperation: { status: string, entityId: string, message: string | null, missingItems: Array<{ itemId: string, itemName: string | null, reason: string }> | null } | null }; +export type OrderEntryOperationQueryQuery = { orderEntryOperation: { status: string, entityId: string | null, message: string | null, missingItems: Array<{ itemId: string, itemName: string | null, reason: string }> | null } | null }; export type UploadFileToOrderEntryMutationMutationVariables = Exact<{ data: IOrderEntryUpload; @@ -3363,7 +3376,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 ServerProductQueryDocument = {"__meta__":{"operationName":"ServerProductQuery","operationHash":"f03d0963fed159ac4bbe11f90ea09c635a66b68c"}} 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; export const ServerProfileQueryDocument = {"__meta__":{"operationName":"ServerProfileQuery","operationHash":"672fe0f00b7b710b63fc6573c0a6b2ec54812b8f"}} as unknown as TypedDocumentString; diff --git a/packages/core/src/components/sections/EmptyState/EmptyState.tsx b/packages/core/src/components/sections/EmptyState/EmptyState.tsx index b1d101adbe..69ef183439 100644 --- a/packages/core/src/components/sections/EmptyState/EmptyState.tsx +++ b/packages/core/src/components/sections/EmptyState/EmptyState.tsx @@ -1,5 +1,5 @@ -import type { PropsWithChildren } from 'react' import { useRouter } from 'next/router' +import type { PropsWithChildren } from 'react' import { Icon as UIIcon, Loader as UILoader } from '@faststore/ui' @@ -9,8 +9,8 @@ import Section from '../Section' import styles from './section.module.scss' -import { EmptyStateDefaultComponents } from './DefaultComponents' import { getOverridableSection } from '../../../sdk/overrides/getOverriddenSection' +import { EmptyStateDefaultComponents } from './DefaultComponents' export interface EmptyStateProps { /** @@ -53,11 +53,12 @@ const useErrorState = () => { query: { errorId, fromUrl }, pathname, asPath, + locale, } = router return { errorId, - fromUrl: fromUrl ?? asPath ?? pathname, + fromUrl: fromUrl ?? (locale ? `/${locale}${asPath}` : asPath) ?? pathname, } } diff --git a/packages/core/src/components/ui/Breadcrumb/Breadcrumb.tsx b/packages/core/src/components/ui/Breadcrumb/Breadcrumb.tsx index 73e112c865..ecb1175473 100644 --- a/packages/core/src/components/ui/Breadcrumb/Breadcrumb.tsx +++ b/packages/core/src/components/ui/Breadcrumb/Breadcrumb.tsx @@ -2,7 +2,6 @@ import type { BreadcrumbProps as UIBreadcrumbProps } from '@faststore/ui' import { Breadcrumb as UIBreadcrumb } from '@faststore/ui' import { memo } from 'react' -import storeConfig from 'discovery.config' import Link from 'src/components/ui/Link' import { useOverrideComponents } from 'src/sdk/overrides/OverrideContext' export interface BreadcrumbProps extends UIBreadcrumbProps { @@ -32,17 +31,11 @@ const Breadcrumb = ({ icon, alt, ...otherProps }: BreadcrumbProps) => { /> } - renderLink={({ itemProps: { item: link, name } }) => - // workaround to display the name without a link since localized slug is not available yet - // TODO: when localized slugs are available remove this workaround - storeConfig.localization?.enabled ? ( - {name} - ) : ( - - {name} - - ) - } + renderLink={({ itemProps: { item: link, name } }) => ( + + {name} + + )} {...Breadcrumb.props} {...otherProps} /> diff --git a/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx b/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx index 6aa2bcf1ef..7e3a20e149 100644 --- a/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx +++ b/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx @@ -6,6 +6,7 @@ import { Icon, Button as UIButton } from '@faststore/ui' import storeConfig from 'discovery.config' import LocalizationSelector from 'src/components/localization/LocalizationSelector' import { useBindingSelector } from 'src/sdk/localization' +import { useLocalizedProduct } from 'src/sdk/localization/LocalizedProductContext' import { useSession } from 'src/sdk/session' export interface LocalizationButtonErrorMessages { @@ -41,6 +42,8 @@ const LocalizationButton = ({ const [isSelectorOpen, setIsSelectorOpen] = useState(false) const buttonRef = useRef(null) + const otherLocales = useLocalizedProduct()?.otherLocales ?? undefined + const { languages, currencies, @@ -52,7 +55,7 @@ const LocalizationButton = ({ reset, isSaveEnabled, error, - } = useBindingSelector() + } = useBindingSelector(otherLocales) const { locale: sessionLocale, currency: sessionCurrency } = useSession() diff --git a/packages/core/src/experimental/index.ts b/packages/core/src/experimental/index.ts index ff9d97e47c..2ed4d34d35 100644 --- a/packages/core/src/experimental/index.ts +++ b/packages/core/src/experimental/index.ts @@ -79,3 +79,5 @@ export type { Locale, } from '../sdk/localization/types' export type { UseBindingSelectorReturn } from '../sdk/localization/useBindingSelector' +export { useLocalizedProduct as useLocalizedProduct_unstable } from '../sdk/localization/LocalizedProductContext' +export type { LocalizedProductLocale as LocalizedProductLocale_unstable } from '../sdk/localization/LocalizedProductContext' diff --git a/packages/core/src/pages/[slug]/p.tsx b/packages/core/src/pages/[slug]/p.tsx index 6f55f2feba..ced2fdc3e7 100644 --- a/packages/core/src/pages/[slug]/p.tsx +++ b/packages/core/src/pages/[slug]/p.tsx @@ -34,6 +34,7 @@ import { getGlobalSectionsData, type GlobalSectionsData, } from 'src/components/cms/GlobalSections' +import { LocalizedProductProvider } from 'src/sdk/localization/LocalizedProductContext' import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig' import { getOfferUrl, useOffer } from 'src/sdk/offer' import PageProvider, { type PDPContext } from 'src/sdk/overrides/PageProvider' @@ -86,6 +87,50 @@ const overwriteMerge = (_: any[], sourceArray: any[]) => sourceArray const isClientOfferEnabled = (storeConfig as StoreConfig).experimental .enableClientOffer +function buildHreflangLinks( + storeConfig: StoreConfig, + otherLocales: Array<{ locale: string; slug: string }> | null | undefined +): Array<{ rel: string; hrefLang: string; href: string }> { + if (!storeConfig.localization?.enabled || !otherLocales?.length) return [] + + const locales = storeConfig.localization.locales as Record + const baseStoreUrl = storeConfig.storeUrl.replace(/\/$/, '') + const defaultLocale = storeConfig.localization.defaultLocale as + | string + | undefined + 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) { + links.push({ + rel: 'alternate', + hrefLang: locale, + href: `${bindingUrl.replace(/\/$/, '')}/${slug}/p`, + }) + } + } + + // PREMISE: the default locale is served at the store root (no locale prefix), + // which is how Next.js i18n sub-path routing works — the `defaultLocale` has no + // prefix while other locales live under `/{locale}`. Hence `x-default` points to + // `${storeUrl}/{slug}/p`. If a store ever serves its default locale under a path + // prefix or a dedicated domain instead of the root, this href must be derived + // from that locale's binding URL instead of `storeUrl`. + const defaultEntry = otherLocales.find((e) => e.locale === defaultLocale) + if (defaultEntry) { + links.push({ + rel: 'alternate', + hrefLang: 'x-default', + href: `${baseStoreUrl}/${defaultEntry.slug}/p`, + }) + } + + return links +} + function Page({ data: server, sections, @@ -123,6 +168,12 @@ function Page({ .toString() } + // hreflang alternate links for multi-locale stores + const hreflangLinks = buildHreflangLinks( + storeConfig as StoreConfig, + server.product.otherLocales + ) + let itemListElements = product.breadcrumbList.itemListElement ?? [] if (itemListElements.length !== 0) { itemListElements = itemListElements.map( @@ -200,13 +251,11 @@ function Page({ content: currency.code, }, ]} + additionalLinkTags={hreflangLinks} titleTemplate={titleTemplate} /> - {/* TODO: when localized slugs are available remove this workaround */} - {!storeConfig.localization?.enabled && ( - - )} + component (not the HTML tag) before rendering it here. */} - - - + + + + + ) } @@ -306,6 +357,11 @@ const query = gql(` productGroupID } + otherLocales { + locale + slug + } + ...ProductDetailsFragment_product } } diff --git a/packages/core/src/sdk/localization/LocalizedProductContext.tsx b/packages/core/src/sdk/localization/LocalizedProductContext.tsx new file mode 100644 index 0000000000..8264dfbb63 --- /dev/null +++ b/packages/core/src/sdk/localization/LocalizedProductContext.tsx @@ -0,0 +1,52 @@ +import { createContext, useContext, useMemo } from 'react' +import type { PropsWithChildren } from 'react' + +export interface LocalizedProductLocale { + locale: string + slug: string +} + +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. + */ + otherLocales: LocalizedProductLocale[] | null +} + +const LocalizedProductContext = createContext(null) + +interface LocalizedProductProviderProps extends PropsWithChildren { + otherLocales: LocalizedProductLocale[] | null | undefined +} + +/** + * Provides localized product data (e.g. otherLocales) to any component in + * the tree — including global components like LocalizationButton that are + * not co-located with the PDP sections. + * + * Set in p.tsx; returns null outside a product page. + */ +export function LocalizedProductProvider({ + otherLocales, + children, +}: Readonly) { + const value = useMemo( + () => ({ otherLocales: otherLocales ?? null }), + [otherLocales] + ) + + return ( + + {children} + + ) +} + +/** + * Returns localized product data for the current page. + * Safe to call from any component — returns null outside a product page. + */ +export function useLocalizedProduct(): LocalizedProductData | null { + return useContext(LocalizedProductContext) +} diff --git a/packages/core/src/sdk/localization/useBindingSelector.ts b/packages/core/src/sdk/localization/useBindingSelector.ts index 9e370ead50..1f9bce3560 100644 --- a/packages/core/src/sdk/localization/useBindingSelector.ts +++ b/packages/core/src/sdk/localization/useBindingSelector.ts @@ -9,8 +9,69 @@ import { isValidUrl, resolveBinding, } from './bindingSelector' +import type { LocalizedProductLocale } from './LocalizedProductContext' import type { BindingSelectorError, Locale } from './types' +const OTHER_LOCALES_STORAGE_PREFIX = 'fs:otherLocales:' + +/** + * Extracts the SKU id from a PDP-shaped pathname ("/.../{slug}-{skuId}/p"). + * Slugs always end with "-{skuId}" (see `getSlug` in the API), so the id is the + * last "-"-separated segment of the path segment that precedes "/p". + * Returns null when the path is not a product page or has no numeric id. + */ +export function getSkuIdFromPdpPath(pathname: string): string | null { + const match = pathname.match(/\/([^/]+)\/p\/?$/) // NOSONAR + const skuId = match?.[1]?.split('-').pop() + + return skuId && /^\d+$/.test(skuId) ? skuId : null +} + +/** + * Persists the localized slug map for a product keyed by its SKU id. This lets + * the locale selector rebuild canonical localized product URLs even after the + * user lands on a context-less page (e.g. a 404 for a locale where the product + * is unavailable), where `otherLocales` is no longer provided by the page. + */ +export function persistOtherLocales( + otherLocales: LocalizedProductLocale[] +): void { + if (typeof window === 'undefined' || !otherLocales.length) return + + const skuId = otherLocales[0]?.slug.split('-').pop() + if (!skuId) return + + try { + window.sessionStorage.setItem( + `${OTHER_LOCALES_STORAGE_PREFIX}${skuId}`, + JSON.stringify(otherLocales) + ) + } catch { + // sessionStorage may be unavailable (private mode/quota); non-critical. + } +} + +/** + * 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. + */ +export function recoverOtherLocales(): LocalizedProductLocale[] | null { + if (typeof window === 'undefined') return null + + const skuId = getSkuIdFromPdpPath(window.location.pathname) + if (!skuId) return null + + try { + const raw = window.sessionStorage.getItem( + `${OTHER_LOCALES_STORAGE_PREFIX}${skuId}` + ) + + return raw ? (JSON.parse(raw) as LocalizedProductLocale[]) : null + } catch { + return null + } +} + interface Currency { code: string name: string @@ -64,9 +125,14 @@ 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. * @returns Object with languages, currencies, selections, and actions */ -export function useBindingSelector(): UseBindingSelectorReturn { +export function useBindingSelector( + otherLocales?: Array<{ locale: string; slug: string }> | null +): UseBindingSelectorReturn { const { locale: currentLocale, currency: currentCurrency } = useSession() const localizationConfig = storeConfig.localization as LocalizationConfig @@ -96,6 +162,15 @@ export function useBindingSelector(): UseBindingSelectorReturn { } }, [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). + useEffect(() => { + if (otherLocales?.length) { + persistOtherLocales(otherLocales) + } + }, [otherLocales]) + // Build language options with disambiguation - returns Record const languages = useMemo( () => buildLanguageOptions(localizationConfig.locales), @@ -181,12 +256,53 @@ export function useBindingSelector(): UseBindingSelectorReturn { return } - // Redirect to binding URL, preserving the current page path and query string + // Prefer the current page's localized slugs. When missing (e.g. we're on a + // 404 for a locale where the product is unavailable) recover the map that was + // persisted while on a working PDP, so we can still build the canonical + // localized product URL instead of dropping the user on the locale home. + const effectiveOtherLocales = otherLocales?.length + ? otherLocales + : recoverOtherLocales() + + if (effectiveOtherLocales?.length) { + // 1. Target locale has a specific translation → use it + const localizedEntry = effectiveOtherLocales.find( + (e) => e.locale === localeCode + ) + + // 2. No translation for target locale → fall back to the default locale slug + // (IS linkText, always in the default locale) to avoid carrying over a + // translated slug from a different locale (e.g. Italian slug on es-ES). + // For an unavailable target this yields a 404 at the product URL (expected). + const fallbackEntry = effectiveOtherLocales.find( + (e) => e.locale === localizationConfig.defaultLocale + ) + + const entry = localizedEntry ?? fallbackEntry + + if (entry) { + const baseUrl = binding.url.replace(/\/$/, '') + window.location.href = `${baseUrl}/${entry.slug}/p${window.location.search}${window.location.hash}` + 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 + // unavailable/untranslated slug it yields a 404 at the product URL, never the + // locale root. window.location.href = buildRedirectUrl( binding.url, `${window.location.pathname}${window.location.search}${window.location.hash}` ) - }, [localeCode, currencyCode, localizationConfig.locales]) + }, [ + localeCode, + currencyCode, + localizationConfig.locales, + localizationConfig.defaultLocale, + otherLocales, + ]) const isSaveEnabled = Boolean(localeCode && currencyCode && !error) diff --git a/packages/core/test/sdk/localization/useBindingSelector.test.tsx b/packages/core/test/sdk/localization/useBindingSelector.test.tsx index 7451cebe5e..e05b17cfb6 100644 --- a/packages/core/test/sdk/localization/useBindingSelector.test.tsx +++ b/packages/core/test/sdk/localization/useBindingSelector.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { buildLanguageOptions, @@ -6,7 +6,37 @@ import { isValidUrl, resolveBinding, } from '../../../src/sdk/localization/bindingSelector' +import type { LocalizedProductLocale } from '../../../src/sdk/localization/LocalizedProductContext' import type { Locale } from '../../../src/sdk/localization/types' +import { + getSkuIdFromPdpPath, + persistOtherLocales, + recoverOtherLocales, +} from '../../../src/sdk/localization/useBindingSelector' + +/** In-memory Storage stub matching the subset used by the helpers. */ +function createFakeStorage() { + const map = new Map() + + return { + getItem: (key: string) => (map.has(key) ? (map.get(key) as string) : null), + setItem: (key: string, value: string) => { + map.set(key, value) + }, + removeItem: (key: string) => { + map.delete(key) + }, + clear: () => map.clear(), + } +} + +/** Stubs a browser-like `window` with the given pathname and a fresh storage. */ +function stubWindow(pathname: string) { + const sessionStorage = createFakeStorage() + vi.stubGlobal('window', { location: { pathname }, sessionStorage }) + + return sessionStorage +} // Test data that mirrors what the hook would receive from discovery.config const mockLocales: Record = { @@ -216,4 +246,112 @@ describe('useBindingSelector integration scenarios', () => { expect(newCurrencies[0]).toBe('EUR') }) }) + + describe('getSkuIdFromPdpPath', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('extracts the SKU id from a locale-prefixed PDP path', () => { + expect( + getSkuIdFromPdpPath( + '/en-CA/adidas-mens-performance-polo-blast-blue-65/p' + ) + ).toBe('65') + }) + + it('extracts the SKU id from a default-locale (root) PDP path', () => { + expect( + getSkuIdFromPdpPath('/adidas-mens-performance-polo-blast-blue-65/p') + ).toBe('65') + }) + + it('extracts the SKU id from a custom-path binding PDP', () => { + expect(getSkuIdFromPdpPath('/europe/it/some-product-slug-12/p')).toBe( + '12' + ) + }) + + it('handles a trailing slash after /p', () => { + expect(getSkuIdFromPdpPath('/pt-BR/some-slug-9/p/')).toBe('9') + }) + + it('returns null for non-PDP paths', () => { + expect(getSkuIdFromPdpPath('/en-CA')).toBeNull() + expect(getSkuIdFromPdpPath('/en-CA/office')).toBeNull() + expect(getSkuIdFromPdpPath('/')).toBeNull() + }) + + it('returns null when the slug has no numeric id', () => { + expect(getSkuIdFromPdpPath('/en-CA/no-numeric-id/p')).toBeNull() + }) + }) + + describe('persist / recover otherLocales (sessionStorage)', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + const otherLocales: LocalizedProductLocale[] = [ + { locale: 'en-US', slug: 'adidas-mens-performance-polo-blast-blue-65' }, + { + locale: 'pt-BR', + slug: 'adidas-polo-performance-masculina-azul-blast-65', + }, + { + locale: 'fr-CA', + slug: 'adidas-polo-performance-homme-bleu-blast-ca-65', + }, + ] + + it('round-trips the map for the SKU referenced by the current path', () => { + // Persisted while on a working PDP (SKU 65). + stubWindow('/adidas-mens-performance-polo-blast-blue-65/p') + persistOtherLocales(otherLocales) + + // Later, on a 404 for en-CA (same SKU 65, default-locale slug in the URL). + vi.stubGlobal('window', { + location: { + pathname: '/en-CA/adidas-mens-performance-polo-blast-blue-65/p', + }, + sessionStorage: window.sessionStorage, + }) + + expect(recoverOtherLocales()).toEqual(otherLocales) + }) + + it('returns null when the current path references a different SKU', () => { + stubWindow('/adidas-mens-performance-polo-blast-blue-65/p') + persistOtherLocales(otherLocales) + + vi.stubGlobal('window', { + location: { pathname: '/en-CA/some-other-product-99/p' }, + sessionStorage: window.sessionStorage, + }) + + expect(recoverOtherLocales()).toBeNull() + }) + + it('returns null when the current path is not a PDP', () => { + stubWindow('/adidas-mens-performance-polo-blast-blue-65/p') + persistOtherLocales(otherLocales) + + vi.stubGlobal('window', { + location: { pathname: '/en-CA' }, + sessionStorage: window.sessionStorage, + }) + + expect(recoverOtherLocales()).toBeNull() + }) + + it('does not persist when otherLocales is empty', () => { + const storage = stubWindow( + '/adidas-mens-performance-polo-blast-blue-65/p' + ) + persistOtherLocales([]) + + expect(recoverOtherLocales()).toBeNull() + expect(storage.getItem('fs:otherLocales:65')).toBeNull() + }) + }) })