diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index 20db18e996..c6e33ac6c9 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -1771,6 +1771,14 @@ export type StoreProduct = { brand: StoreBrand; /** List of items consisting of chain linked web pages, ending with the current page. */ breadcrumbList: StoreBreadcrumbList; + /** + * Slug for the store's default locale, including the SKU ID suffix. + * Navigation fallback for the locale selector when the target locale is absent + * from `otherLocales`. Best-effort: it may point at a slug the catalog has not + * registered for the default locale, so it must never be used to build hreflang + * annotations. Only populated when localization is enabled. + */ + defaultLocaleSlug?: Maybe; /** Delivery Promise product's badge. */ deliveryPromiseBadges?: Maybe>>; /** Product description. */ diff --git a/packages/api/src/platforms/vtex/resolvers/product.ts b/packages/api/src/platforms/vtex/resolvers/product.ts index e6144c8f1e..2812d6a6e4 100644 --- a/packages/api/src/platforms/vtex/resolvers/product.ts +++ b/packages/api/src/platforms/vtex/resolvers/product.ts @@ -77,7 +77,15 @@ export async function getLocalizedProductEntry( categories: result.categories ?? [], availableLinkIds: result.availableLinkIds ?? {}, } - } catch { + } catch (err) { + // Without this the fallback is silent: the breadcrumb renders the IS + // category names, always in the default language, and the page can be + // frozen in that state by `revalidate: false`. + console.warn( + `[getLocalizedProductEntry] failed to load product ${productId} for locale ${locale}:`, + err + ) + return null } })() @@ -314,7 +322,6 @@ export const StoreProduct: Record> & { const productId = root.isVariantOf.productId const itemId = root.itemId const locale = ctx.storage.locale - const defaultLocale = getDefaultLocale(ctx) // availableLinkIds returns localized slug for every locale, // we fetch for the current locale (reusing the request-scoped cache shared with the slug and @@ -324,29 +331,56 @@ export const StoreProduct: Record> & { if (!entry?.availableLinkIds) 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. + // availableLinkIds is identical whichever locale the Dataplane is queried + // with, so deriving every slug from it keeps the hreflang cluster + // reciprocal. The Intelligent Search linkText cannot stand in for a + // missing entry: it is localized to the locale being browsed, which would + // both point other locales at a 404 and make the advertised set depend on + // which locale served the request. const linkId = availableLinkIds[configuredLocale] + + // Locales with no registered localized slug are omitted rather than + // guessed, so an alternate is only ever advertised for a slug the catalog + // actually resolves. A product with no translations advertises no + // alternates at all, from any locale. Navigating to an omitted locale is + // served by `defaultLocaleSlug`, which is free to guess precisely because + // it never reaches an hreflang annotation. return linkId ? { locale: configuredLocale, slug: getSlug(linkId, itemId) } : null }) .filter((e): e is { locale: string; slug: string } => e !== null) }, + defaultLocaleSlug: async (root, _args, ctx) => { + if (!isLocalizationEnabled(ctx)) return null + + const defaultLocale = getDefaultLocale(ctx) + + if (!defaultLocale) return null + + const { productId, linkText } = root.isVariantOf + const itemId = root.itemId + + // Reuses the request-scoped entry `otherLocales` already loads, so serving + // both fields costs a single Dataplane call. + const entry = await getLocalizedProductEntry( + ctx, + productId, + ctx.storage.locale + ) + + // linkText is localized to the locale being browsed, which is what makes it + // unusable for hreflang. It is acceptable here because an untranslated + // product carries the same slug in every locale, so linkText *is* the + // default-locale slug in the exact case where availableLinkIds is empty. + // A product translated for the browsed locale but not for the default one + // still yields a wrong guess; that URL 404s, which is the pre-existing + // behavior and still better than dropping the shopper on the locale root. + const linkId = entry?.availableLinkIds?.[defaultLocale] ?? linkText + + return getSlug(linkId, itemId) + }, } diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 29f71c718b..d0477ac3d5 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -109,10 +109,11 @@ const shouldFallbackToProductRoute = (error: unknown) => * guards that the fetched sku is the one we actually asked for, throwing * SLUG_MISMATCH_ERROR (caught by the caller) when it isn't. * - * 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. + * When localization is enabled, the slug prefix may be a localized LinkId that + * differs from the IS linkText. linkText follows the locale being browsed + * rather than the default one, so a mismatch is not by itself evidence of a + * wrong sku. We validate against the Catalog Dataplane API before rejecting + * the slug. */ async function assertSkuMatchesSlug( ctx: GraphqlContext, diff --git a/packages/api/src/platforms/vtex/typeDefs/product.graphql b/packages/api/src/platforms/vtex/typeDefs/product.graphql index 409cc8dfef..ca3f5a60fd 100644 --- a/packages/api/src/platforms/vtex/typeDefs/product.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/product.graphql @@ -99,6 +99,14 @@ type StoreProduct { Only populated when localization is enabled. """ otherLocales: [StoreProductLocale!] + """ + Slug for the store's default locale, including the SKU ID suffix. + Navigation fallback for the locale selector when the target locale is absent + from `otherLocales`. Best-effort: it may point at a slug the catalog has not + registered for the default locale, so it must never be used to build hreflang + annotations. Only populated when localization is enabled. + """ + defaultLocaleSlug: String } """ diff --git a/packages/api/test/unit/platforms/vtex/resolvers/product.test.ts b/packages/api/test/unit/platforms/vtex/resolvers/product.test.ts index 7ffe52d560..7e5541d6a3 100644 --- a/packages/api/test/unit/platforms/vtex/resolvers/product.test.ts +++ b/packages/api/test/unit/platforms/vtex/resolvers/product.test.ts @@ -275,7 +275,7 @@ describe('StoreProduct', () => { const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ linkId: 'blue-shirt', categories: [], - availableLinkIds: { 'pt-BR': 'camisa-azul' }, + availableLinkIds: { 'en-US': 'blue-shirt', 'pt-BR': 'camisa-azul' }, }) const root = makeRoot() @@ -289,20 +289,18 @@ describe('StoreProduct', () => { 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 () => { + it('omits locales with no entry in availableLinkIds', async () => { const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ linkId: 'blue-shirt', categories: [], - availableLinkIds: {}, // pt-BR not translated + availableLinkIds: { 'en-US': 'blue-shirt' }, // pt-BR not translated }) const root = makeRoot() @@ -320,6 +318,137 @@ describe('StoreProduct', () => { expect(result?.find((e: any) => e.locale === 'pt-BR')).toBeUndefined() }) + it('returns no alternates when the product has no registered localized slug', async () => { + const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ + linkId: 'blue-shirt', + categories: [], + availableLinkIds: {}, + }) + + 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).toEqual([]) + }) + + it('resolves the default locale from availableLinkIds while browsing another locale', async () => { + // Intelligent Search localizes linkText to the locale being browsed, so a + // pt-BR request reports the pt-BR slug as linkText. The en-US alternate must + // come from availableLinkIds, otherwise it points at a URL that 404s. + const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ + linkId: 'camisa-azul', + categories: [], + availableLinkIds: { 'en-US': 'blue-shirt', 'pt-BR': 'camisa-azul' }, + }) + + const root = makeRoot({ linkText: 'camisa-azul' }) + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + 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).toContainEqual({ + locale: 'pt-BR', + slug: 'camisa-azul-100', + }) + // The whole map comes from a single response — no per-locale fan-out. + expect(getLocalizedProduct).toHaveBeenCalledTimes(1) + }) + + it('omits the default locale rather than guessing when it is absent from availableLinkIds', async () => { + const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ + linkId: 'camisa-azul', + categories: [], + availableLinkIds: { 'pt-BR': 'camisa-azul' }, + }) + + const root = makeRoot({ linkText: 'camisa-azul' }) + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct, + }) + + const result = await (StoreProduct.otherLocales as any)(root, {}, ctx) + + expect(result?.find((e: any) => e.locale === 'en-US')).toBeUndefined() + expect(result).toContainEqual({ + locale: 'pt-BR', + slug: 'camisa-azul-100', + }) + }) + + it('omits the locale being browsed when it has no registered localized slug', async () => { + // linkText is the untranslated slug Intelligent Search reports for an + // untranslated locale. Advertising it as the pt-BR alternate would make the + // set depend on the serving locale, since the en-US response cannot know it. + const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ + linkId: 'blue-shirt', + categories: [], + availableLinkIds: { 'en-US': 'blue-shirt' }, + }) + + const root = makeRoot({ linkText: 'blue-shirt' }) + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct, + }) + + const result = await (StoreProduct.otherLocales as any)(root, {}, ctx) + + expect(result?.find((e: any) => e.locale === 'pt-BR')).toBeUndefined() + expect(result).toContainEqual({ locale: 'en-US', slug: 'blue-shirt-100' }) + }) + + it('advertises the same alternates whichever locale serves the product', async () => { + // hreflang annotations are only honoured when reciprocal: every locale + // variant of a product must advertise the same cluster. Intelligent Search + // reports linkText in the browsed locale, so the alternates may only be + // derived from availableLinkIds, which is locale-independent. + const availableLinkIds = { 'en-US': 'blue-shirt' } + + const resolveFrom = (locale: string, linkText: string) => + (StoreProduct.otherLocales as any)( + makeRoot({ linkText }), + {}, + makeCtx({ + localizationEnabled: true, + locale, + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct: vi.fn().mockResolvedValueOnce({ + linkId: linkText, + categories: [], + availableLinkIds, + }), + }) + ) + + const fromDefault = await resolveFrom('en-US', 'blue-shirt') + const fromUntranslated = await resolveFrom('pt-BR', 'blue-shirt') + + expect(fromUntranslated).toEqual(fromDefault) + }) + it('returns null when the Dataplane API throws', async () => { const getLocalizedProduct = vi .fn() @@ -341,6 +470,108 @@ describe('StoreProduct', () => { expect(result).toBeNull() }) + }) + + describe('defaultLocaleSlug', () => { + it('returns null when localization is disabled', async () => { + const result = await (StoreProduct.defaultLocaleSlug as any)( + makeRoot(), + {}, + makeCtx() + ) + + expect(result).toBeNull() + }) + + it('uses the default locale entry from availableLinkIds', async () => { + const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ + linkId: 'camisa-azul', + categories: [], + availableLinkIds: { 'en-US': 'blue-shirt', 'pt-BR': 'camisa-azul' }, + }) + + const result = await (StoreProduct.defaultLocaleSlug as any)( + makeRoot({ linkText: 'camisa-azul' }), + {}, + makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct, + }) + ) + + expect(result).toBe('blue-shirt-100') + }) + + it('falls back to linkText for an untranslated product', async () => { + // The case otherLocales deliberately gives up on: no registered slug for + // any locale. linkText is the same slug in every locale here, so it is the + // default-locale slug and the selector can still reach the product. + const getLocalizedProduct = vi.fn().mockResolvedValueOnce({ + linkId: 'blue-shirt', + categories: [], + availableLinkIds: {}, + }) + + const result = await (StoreProduct.defaultLocaleSlug as any)( + makeRoot({ linkText: 'blue-shirt' }), + {}, + makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct, + }) + ) + + expect(result).toBe('blue-shirt-100') + }) + + it('still resolves a slug when the Dataplane call fails', async () => { + const getLocalizedProduct = vi + .fn() + .mockRejectedValueOnce(new Error('API error')) + + const result = await (StoreProduct.defaultLocaleSlug as any)( + makeRoot({ linkText: 'blue-shirt' }), + {}, + makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct, + }) + ) + + expect(result).toBe('blue-shirt-100') + }) + + it('shares the Dataplane call with otherLocales', async () => { + const getLocalizedProduct = vi.fn().mockResolvedValue({ + linkId: 'camisa-azul', + categories: [], + availableLinkIds: { 'en-US': 'blue-shirt', 'pt-BR': 'camisa-azul' }, + }) + + const root = makeRoot({ linkText: 'camisa-azul' }) + const ctx = makeCtx({ + localizationEnabled: true, + locale: 'pt-BR', + locales: { 'en-US': {}, 'pt-BR': {} }, + defaultLocale: 'en-US', + getLocalizedProduct, + cache: new Map(), + }) + + await (StoreProduct.otherLocales as any)(root, {}, ctx) + await (StoreProduct.defaultLocaleSlug as any)(root, {}, ctx) + + expect(getLocalizedProduct).toHaveBeenCalledTimes(1) + }) it('reuses a cached entry with availableLinkIds and skips the API call', async () => { const cachedEntry = { diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index 753b721bfe..b4324cbdaa 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -18,7 +18,6 @@ type Documents = { "\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\t\tunitMultiplier\n\n isVariantOf {\n productGroupID\n name\n\t\t\tskuVariants {\n\t\t\t\tallVariantsByName\n\t\t\t\tactiveVariations\n\t\t\t\tslugsMap\n\t\t\t\tavailableVariations\n\t\t\t}\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n priceWithTaxes\n quantity\n priceToken\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n hasSpecifications\n\n unitMultiplier\n\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n deliveryPromiseBadges {\n typeName\n }\n }\n": typeof types.ProductSummary_ProductFragmentDoc, "\n fragment Filter_facets on StoreFacet {\n ... on StoreFacetRange {\n key\n label\n\n min {\n selected\n absolute\n }\n\n max {\n selected\n absolute\n }\n\n __typename\n }\n ... on StoreFacetBoolean {\n key\n label\n values {\n label\n value\n selected\n quantity\n }\n\n __typename\n }\n }\n": typeof types.Filter_FacetsFragmentDoc, "\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n quantity\n priceToken\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n": typeof types.ProductDetailsFragment_ProductFragmentDoc, - "query ClientRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": typeof types.ClientRecommendationsQueryDocument, "\n fragment ProductComparisonFragment_product on StoreProduct {\n id: productID\n sku\n slug\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n quantity\n listPriceWithTaxes\n priceToken\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n hasSpecifications\n\n skuSpecifications {\n field {\n id\n name\n originalName\n }\n values {\n name\n id\n fieldId\n originalName\n }\n }\n\n specificationGroups {\n name\n originalName\n specifications {\n name\n originalName\n values\n }\n }\n }\n": typeof types.ProductComparisonFragment_ProductFragmentDoc, "\n fragment ProductSKUMatrixSidebarFragment_product on StoreProduct {\n id: productID\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n\t\t\t\t\tsku\n name\n image {\n url\n alternateName\n }\n offers {\n highPrice\n lowPrice\n lowPriceWithTaxes\n offerCount\n priceCurrency\n offers {\n listPrice\n listPriceWithTaxes\n sellingPrice\n priceCurrency\n price\n priceWithTaxes\n priceValidUntil\n itemCondition\n availability\n quantity\n priceToken\n }\n }\n additionalProperty {\n propertyID\n value\n name\n valueReference\n }\n }\n }\n }\n }\n": typeof types.ProductSkuMatrixSidebarFragment_ProductFragmentDoc, "\n fragment ClientManyProducts on Query {\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n\n ) {\n products {\n pageInfo {\n totalCount\n }\n }\n }\n }\n": typeof types.ClientManyProductsFragmentDoc, @@ -31,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 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 priceToken\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 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 priceToken\n seller {\n identifier\n }\n }\n }\n\n isVariantOf {\n productGroupID\n }\n\n otherLocales {\n locale\n slug\n }\n\n defaultLocaleSlug\n\n ...ProductDetailsFragment_product\n }\n }\n": typeof types.ServerProductQueryDocument, "\n query ServerListCardsQuery {\n listCreditCards {\n list {\n accountId\n bin\n cardNumber\n cardLabel\n paymentSystem\n paymentSystemName\n isDefault\n isActive\n origin\n }\n }\n accountProfile {\n name\n }\n hasAdHocCardAccess\n }\n": typeof types.ServerListCardsQueryDocument, "\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, @@ -61,6 +60,7 @@ type Documents = { "\n query ClientManyProductsQuery(\n $first: Int!\n $after: String\n $sort: StoreSort!\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]!\n $sponsoredCount: Int\n ) {\n ...ClientManyProducts\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n ) {\n products {\n pageInfo {\n totalCount\n }\n edges {\n node {\n ...ProductSummary_product\n }\n }\n }\n }\n }\n": typeof types.ClientManyProductsQueryDocument, "\n query ClientManyProductsSelectedQuery(\n $productIds: [String!]!\n ) {\n products(productIds: $productIds) {\n ...ProductComparisonFragment_product\n }\n }\n": typeof types.ClientManyProductsSelectedQueryDocument, "\n query ClientProfileQuery($id: String!) {\n profile(id: $id) {\n addresses {\n country\n postalCode\n geoCoordinate\n city\n }\n }\n }\n": typeof types.ClientProfileQueryDocument, + "query ClientRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": typeof types.ClientRecommendationsQueryDocument, "\n query ClientSearchSuggestionsQuery(\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]\n ) {\n ...ClientSearchSuggestions\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n products {\n ...ProductSummary_product\n }\n }\n products {\n pageInfo {\n totalCount\n }\n }\n metadata {\n ...SearchEvent_metadata\n }\n searchId\n }\n }\n": typeof types.ClientSearchSuggestionsQueryDocument, "\n query ClientTopSearchSuggestionsQuery(\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]\n ) {\n ...ClientTopSearchSuggestions\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n }\n }\n }\n": typeof types.ClientTopSearchSuggestionsQueryDocument, "\n mutation ValidateSession($session: IStoreSession!, $search: String!) {\n validateSession(session: $session, search: $search) {\n locale\n channel\n country\n addressType\n postalCode\n city\n deliveryMode {\n deliveryChannel\n deliveryMethod\n deliveryWindow {\n startDate\n endDate\n }\n }\n geoCoordinates {\n latitude\n longitude\n }\n currency {\n code\n symbol\n }\n person {\n id\n email\n givenName\n familyName\n }\n b2b {\n customerId\n isRepresentative\n unitName\n unitId\n firstName\n lastName\n userName\n userEmail\n savedPostalCode\n contractName\n organizationManager\n }\n marketingData {\n utmCampaign\n utmMedium\n utmSource\n utmiCampaign\n utmiPage\n utmiPart\n }\n refreshAfter\n }\n }\n": typeof types.ValidateSessionDocument, @@ -71,7 +71,6 @@ const documents: Documents = { "\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\t\tunitMultiplier\n\n isVariantOf {\n productGroupID\n name\n\t\t\tskuVariants {\n\t\t\t\tallVariantsByName\n\t\t\t\tactiveVariations\n\t\t\t\tslugsMap\n\t\t\t\tavailableVariations\n\t\t\t}\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n priceWithTaxes\n quantity\n priceToken\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n hasSpecifications\n\n unitMultiplier\n\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n deliveryPromiseBadges {\n typeName\n }\n }\n": types.ProductSummary_ProductFragmentDoc, "\n fragment Filter_facets on StoreFacet {\n ... on StoreFacetRange {\n key\n label\n\n min {\n selected\n absolute\n }\n\n max {\n selected\n absolute\n }\n\n __typename\n }\n ... on StoreFacetBoolean {\n key\n label\n values {\n label\n value\n selected\n quantity\n }\n\n __typename\n }\n }\n": types.Filter_FacetsFragmentDoc, "\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n quantity\n priceToken\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n": types.ProductDetailsFragment_ProductFragmentDoc, - "query ClientRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": types.ClientRecommendationsQueryDocument, "\n fragment ProductComparisonFragment_product on StoreProduct {\n id: productID\n sku\n slug\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n quantity\n listPriceWithTaxes\n priceToken\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n hasSpecifications\n\n skuSpecifications {\n field {\n id\n name\n originalName\n }\n values {\n name\n id\n fieldId\n originalName\n }\n }\n\n specificationGroups {\n name\n originalName\n specifications {\n name\n originalName\n values\n }\n }\n }\n": types.ProductComparisonFragment_ProductFragmentDoc, "\n fragment ProductSKUMatrixSidebarFragment_product on StoreProduct {\n id: productID\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n\t\t\t\t\tsku\n name\n image {\n url\n alternateName\n }\n offers {\n highPrice\n lowPrice\n lowPriceWithTaxes\n offerCount\n priceCurrency\n offers {\n listPrice\n listPriceWithTaxes\n sellingPrice\n priceCurrency\n price\n priceWithTaxes\n priceValidUntil\n itemCondition\n availability\n quantity\n priceToken\n }\n }\n additionalProperty {\n propertyID\n value\n name\n valueReference\n }\n }\n }\n }\n }\n": types.ProductSkuMatrixSidebarFragment_ProductFragmentDoc, "\n fragment ClientManyProducts on Query {\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n\n ) {\n products {\n pageInfo {\n totalCount\n }\n }\n }\n }\n": types.ClientManyProductsFragmentDoc, @@ -84,7 +83,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 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 priceToken\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 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 priceToken\n seller {\n identifier\n }\n }\n }\n\n isVariantOf {\n productGroupID\n }\n\n otherLocales {\n locale\n slug\n }\n\n defaultLocaleSlug\n\n ...ProductDetailsFragment_product\n }\n }\n": types.ServerProductQueryDocument, "\n query ServerListCardsQuery {\n listCreditCards {\n list {\n accountId\n bin\n cardNumber\n cardLabel\n paymentSystem\n paymentSystemName\n isDefault\n isActive\n origin\n }\n }\n accountProfile {\n name\n }\n hasAdHocCardAccess\n }\n": types.ServerListCardsQueryDocument, "\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, @@ -114,6 +113,7 @@ const documents: Documents = { "\n query ClientManyProductsQuery(\n $first: Int!\n $after: String\n $sort: StoreSort!\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]!\n $sponsoredCount: Int\n ) {\n ...ClientManyProducts\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n ) {\n products {\n pageInfo {\n totalCount\n }\n edges {\n node {\n ...ProductSummary_product\n }\n }\n }\n }\n }\n": types.ClientManyProductsQueryDocument, "\n query ClientManyProductsSelectedQuery(\n $productIds: [String!]!\n ) {\n products(productIds: $productIds) {\n ...ProductComparisonFragment_product\n }\n }\n": types.ClientManyProductsSelectedQueryDocument, "\n query ClientProfileQuery($id: String!) {\n profile(id: $id) {\n addresses {\n country\n postalCode\n geoCoordinate\n city\n }\n }\n }\n": types.ClientProfileQueryDocument, + "query ClientRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n": types.ClientRecommendationsQueryDocument, "\n query ClientSearchSuggestionsQuery(\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]\n ) {\n ...ClientSearchSuggestions\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n products {\n ...ProductSummary_product\n }\n }\n products {\n pageInfo {\n totalCount\n }\n }\n metadata {\n ...SearchEvent_metadata\n }\n searchId\n }\n }\n": types.ClientSearchSuggestionsQueryDocument, "\n query ClientTopSearchSuggestionsQuery(\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]\n ) {\n ...ClientTopSearchSuggestions\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n }\n }\n }\n": types.ClientTopSearchSuggestionsQueryDocument, "\n mutation ValidateSession($session: IStoreSession!, $search: String!) {\n validateSession(session: $session, search: $search) {\n locale\n channel\n country\n addressType\n postalCode\n city\n deliveryMode {\n deliveryChannel\n deliveryMethod\n deliveryWindow {\n startDate\n endDate\n }\n }\n geoCoordinates {\n latitude\n longitude\n }\n currency {\n code\n symbol\n }\n person {\n id\n email\n givenName\n familyName\n }\n b2b {\n customerId\n isRepresentative\n unitName\n unitId\n firstName\n lastName\n userName\n userEmail\n savedPostalCode\n contractName\n organizationManager\n }\n marketingData {\n utmCampaign\n utmMedium\n utmSource\n utmiCampaign\n utmiPage\n utmiPart\n }\n refreshAfter\n }\n }\n": types.ValidateSessionDocument, @@ -133,10 +133,6 @@ export function gql(source: "\n fragment Filter_facets on StoreFacet {\n ... * 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 ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n name\n productID\n }\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n quantity\n priceToken\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n"): typeof import('./graphql').ProductDetailsFragment_ProductFragmentDoc; -/** - * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function gql(source: "query ClientRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n"): typeof import('./graphql').ClientRecommendationsQueryDocument; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -188,7 +184,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 priceToken\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; +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 priceToken\n seller {\n identifier\n }\n }\n }\n\n isVariantOf {\n productGroupID\n }\n\n otherLocales {\n locale\n slug\n }\n\n defaultLocaleSlug\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. */ @@ -305,6 +301,10 @@ export function gql(source: "\n query ClientManyProductsSelectedQuery(\n $pr * 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 ClientProfileQuery($id: String!) {\n profile(id: $id) {\n addresses {\n country\n postalCode\n geoCoordinate\n city\n }\n }\n }\n"): typeof import('./graphql').ClientProfileQueryDocument; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "query ClientRecommendationsQuery(\n $campaignVrn: String!\n $userId: String\n $products: [String!]\n) {\n recommendations(\n userId: $userId\n campaignVrn: $campaignVrn\n products: $products\n ) {\n products {\n ...ProductSummary_product\n }\n correlationId\n campaign {\n id\n title\n type\n }\n }\n}\n"): typeof import('./graphql').ClientRecommendationsQueryDocument; /** * 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 e6ec6fca02..68bf5920be 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -1681,6 +1681,14 @@ export type StoreProduct = { brand: StoreBrand; /** List of items consisting of chain linked web pages, ending with the current page. */ breadcrumbList: StoreBreadcrumbList; + /** + * Slug for the store's default locale, including the SKU ID suffix. + * Navigation fallback for the locale selector when the target locale is absent + * from `otherLocales`. Best-effort: it may point at a slug the catalog has not + * registered for the default locale, so it must never be used to build hreflang + * annotations. Only populated when localization is enabled. + */ + defaultLocaleSlug: Maybe; /** Delivery Promise product's badge. */ deliveryPromiseBadges: Maybe>>; /** Product description. */ @@ -2782,15 +2790,6 @@ export type Filter_FacetsFragment = export type ProductDetailsFragment_ProductFragment = { sku: string, name: string, gtin: string, description: string, unitMultiplier: number | null, id: string, isVariantOf: { name: string, productGroupID: string, skuVariants: { activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, brand: { name: string }, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, priceWithTaxes: number, listPrice: number, listPriceWithTaxes: number, quantity: number, priceToken: string | null, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }> }; -export type ClientRecommendationsQueryQueryVariables = Exact<{ - campaignVrn: Scalars['String']['input']; - userId: InputMaybe; - products: InputMaybe | Scalars['String']['input']>; -}>; - - -export type ClientRecommendationsQueryQuery = { recommendations: { correlationId: string, products: Array<{ slug: string, sku: string, name: string, gtin: string, unitMultiplier: number | null, hasSpecifications: boolean | null, id: string, brand: { name: string, brandName: string }, isVariantOf: { productGroupID: string, name: string, skuVariants: { allVariantsByName: any | null, activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, listPrice: number, listPriceWithTaxes: number, priceWithTaxes: number, quantity: number, priceToken: string | null, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }>, advertisement: { adId: string, adResponseId: string } | null, deliveryPromiseBadges: Array<{ typeName: string | null } | null> | null }>, campaign: { id: string, title: string | null, type: string } } }; - export type ProductComparisonFragment_ProductFragment = { sku: string, slug: string, name: string, gtin: string, description: string, unitMultiplier: number | null, hasSpecifications: boolean | null, id: string, isVariantOf: { name: string, productGroupID: string, skuVariants: { activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, brand: { name: string }, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, priceWithTaxes: number, listPrice: number, quantity: number, listPriceWithTaxes: number, priceToken: string | null, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }>, advertisement: { adId: string, adResponseId: string } | null, skuSpecifications: Array<{ field: { id: string | null, name: string, originalName: string | null }, values: Array<{ name: string, id: string | null, fieldId: string | null, originalName: string | null }> }>, specificationGroups: Array<{ name: string, originalName: string, specifications: Array<{ name: string, originalName: string, values: Array }> }> }; export type ProductSkuMatrixSidebarFragment_ProductFragment = { id: string, isVariantOf: { name: string, productGroupID: string, skuVariants: { activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ sku: string, name: string, image: Array<{ url: string, alternateName: string }>, offers: { highPrice: number, lowPrice: number, lowPriceWithTaxes: number, offerCount: number, priceCurrency: string, offers: Array<{ listPrice: number, listPriceWithTaxes: number, sellingPrice: number, priceCurrency: string, price: number, priceWithTaxes: number, priceValidUntil: string, itemCondition: string, availability: string, quantity: number, priceToken: string | null }> }, additionalProperty: Array<{ propertyID: string, value: any, name: string, valueReference: any }> }> | null } | null } }; @@ -2828,7 +2827,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, priceToken: string | null, 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 ServerProductQueryQuery = { product: { sku: string, gtin: string, mpn: string, name: string, description: string, releaseDate: string, defaultLocaleSlug: string | null, 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, priceToken: string | null, 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 ServerListCardsQueryQueryVariables = Exact<{ [key: string]: never; }>; @@ -3054,6 +3053,15 @@ export type ClientProfileQueryQueryVariables = Exact<{ export type ClientProfileQueryQuery = { profile: { addresses: Array<{ country: string | null, postalCode: string | null, geoCoordinate: Array | null, city: string | null } | null> | null } | null }; +export type ClientRecommendationsQueryQueryVariables = Exact<{ + campaignVrn: Scalars['String']['input']; + userId: InputMaybe; + products: InputMaybe | Scalars['String']['input']>; +}>; + + +export type ClientRecommendationsQueryQuery = { recommendations: { correlationId: string, products: Array<{ slug: string, sku: string, name: string, gtin: string, unitMultiplier: number | null, hasSpecifications: boolean | null, id: string, brand: { name: string, brandName: string }, isVariantOf: { productGroupID: string, name: string, skuVariants: { allVariantsByName: any | null, activeVariations: any | null, slugsMap: any | null, availableVariations: any | null, allVariantProducts: Array<{ name: string, productID: string }> | null } | null }, image: Array<{ url: string, alternateName: string }>, offers: { lowPrice: number, lowPriceWithTaxes: number, offers: Array<{ availability: string, price: number, listPrice: number, listPriceWithTaxes: number, priceWithTaxes: number, quantity: number, priceToken: string | null, seller: { identifier: string } }> }, additionalProperty: Array<{ propertyID: string, name: string, value: any, valueReference: any }>, advertisement: { adId: string, adResponseId: string } | null, deliveryPromiseBadges: Array<{ typeName: string | null } | null> | null }>, campaign: { id: string, title: string | null, type: string } } }; + export type ClientSearchSuggestionsQueryQueryVariables = Exact<{ term: Scalars['String']['input']; selectedFacets: InputMaybe | IStoreSelectedFacet>; @@ -3615,10 +3623,9 @@ export const SearchEvent_MetadataFragmentDoc = new TypedDocumentString(` fuzzy } `, {"fragmentName":"SearchEvent_metadata"}) as unknown as TypedDocumentString; -export const ClientRecommendationsQueryDocument = {"__meta__":{"operationName":"ClientRecommendationsQuery","operationHash":"b227071b0388469d7b90d04cff4cbc11f8f134d4"}} as unknown as TypedDocumentString; export const ServerAccountPageQueryDocument = {"__meta__":{"operationName":"ServerAccountPageQuery","operationHash":"9baae331b75848a310fecb457e8c971ae27897ff"}} as unknown as TypedDocumentString; export const ServerCollectionPageQueryDocument = {"__meta__":{"operationName":"ServerCollectionPageQuery","operationHash":"d46841b30ae1f6350021b5cf02f253d56c848664"}} as unknown as TypedDocumentString; -export const ServerProductQueryDocument = {"__meta__":{"operationName":"ServerProductQuery","operationHash":"b89e93519be01aebc01c402489a0ae640d38675a"}} as unknown as TypedDocumentString; +export const ServerProductQueryDocument = {"__meta__":{"operationName":"ServerProductQuery","operationHash":"13fc9efa8628b91971e3a8aa985e6cdbed70adae"}} as unknown as TypedDocumentString; export const ServerListCardsQueryDocument = {"__meta__":{"operationName":"ServerListCardsQuery","operationHash":"392cf85d18d66b94d6ea8d6b2a3c6ffb5c94683d"}} 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; @@ -3647,6 +3654,7 @@ export const ClientProductQueryDocument = {"__meta__":{"operationName":"ClientPr export const ClientManyProductsQueryDocument = {"__meta__":{"operationName":"ClientManyProductsQuery","operationHash":"ee14fd92b4a04fc3751efcbfcb60b3886d912253"}} as unknown as TypedDocumentString; export const ClientManyProductsSelectedQueryDocument = {"__meta__":{"operationName":"ClientManyProductsSelectedQuery","operationHash":"7e1b06c167e411905ceac9db11c1a6a7892cfdd6"}} as unknown as TypedDocumentString; export const ClientProfileQueryDocument = {"__meta__":{"operationName":"ClientProfileQuery","operationHash":"34ea14c0d4a57ddf9bc11e4be0cd2b5a6506d3d4"}} as unknown as TypedDocumentString; +export const ClientRecommendationsQueryDocument = {"__meta__":{"operationName":"ClientRecommendationsQuery","operationHash":"b227071b0388469d7b90d04cff4cbc11f8f134d4"}} as unknown as TypedDocumentString; export const ClientSearchSuggestionsQueryDocument = {"__meta__":{"operationName":"ClientSearchSuggestionsQuery","operationHash":"ee57cd392fdfde90620787c9954e2ecae47881f9"}} as unknown as TypedDocumentString; export const ClientTopSearchSuggestionsQueryDocument = {"__meta__":{"operationName":"ClientTopSearchSuggestionsQuery","operationHash":"e2385b0f11726d0068f96548f57a8dd441c064e3"}} as unknown as TypedDocumentString; export const ValidateSessionDocument = {"__meta__":{"operationName":"ValidateSession","operationHash":"8c3a5999496e227f167e9dc79697e6c478d48a9e"}} as unknown as TypedDocumentString; diff --git a/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx b/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx index f74e549e51..2a820469d5 100644 --- a/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx +++ b/packages/core/src/components/ui/LocalizationButton/LocalizationButton.tsx @@ -42,8 +42,10 @@ const LocalizationButton = ({ const [isSelectorOpen, setIsSelectorOpen] = useState(false) const buttonRef = useRef(null) - const otherLocales = useLocalizedProduct()?.otherLocales ?? undefined - const urlSuffix = useLocalizedProduct()?.urlSuffix ?? '/p' + const localizedProduct = useLocalizedProduct() + const otherLocales = localizedProduct?.otherLocales ?? undefined + const urlSuffix = localizedProduct?.urlSuffix ?? '/p' + const defaultLocaleSlug = localizedProduct?.defaultLocaleSlug ?? undefined const { languages, @@ -56,7 +58,7 @@ const LocalizationButton = ({ reset, isSaveEnabled, error, - } = useBindingSelector(otherLocales, urlSuffix) + } = useBindingSelector(otherLocales, urlSuffix, defaultLocaleSlug) const { locale: sessionLocale, currency: sessionCurrency } = useSession() diff --git a/packages/core/src/pages/[slug]/p.tsx b/packages/core/src/pages/[slug]/p.tsx index 06dcc9bb1b..3de0db6a21 100644 --- a/packages/core/src/pages/[slug]/p.tsx +++ b/packages/core/src/pages/[slug]/p.tsx @@ -352,7 +352,10 @@ function Page({ If needed, wrap your component in a
component (not the HTML tag) before rendering it here. */} - + (null) type LocalizedProductProviderProps = Readonly< PropsWithChildren<{ otherLocales: LocalizedProductLocale[] | null | undefined + defaultLocaleSlug?: string | null /** * Suffix to append to the localized slug when building the redirect URL. * Defaults to '/p' (product pages). Pass '' for collection/PLP pages. @@ -41,12 +48,17 @@ type LocalizedProductProviderProps = Readonly< */ export function LocalizedProductProvider({ otherLocales, + defaultLocaleSlug, urlSuffix = '/p', children, }: LocalizedProductProviderProps) { const value = useMemo( - () => ({ otherLocales: otherLocales ?? null, urlSuffix }), - [otherLocales, urlSuffix] + () => ({ + otherLocales: otherLocales ?? null, + defaultLocaleSlug: defaultLocaleSlug ?? null, + urlSuffix, + }), + [otherLocales, defaultLocaleSlug, urlSuffix] ) return ( diff --git a/packages/core/src/sdk/localization/bindingSelector.ts b/packages/core/src/sdk/localization/bindingSelector.ts index df5672809c..47d539c23f 100644 --- a/packages/core/src/sdk/localization/bindingSelector.ts +++ b/packages/core/src/sdk/localization/bindingSelector.ts @@ -1,3 +1,4 @@ +import type { LocalizedProductLocale } from './LocalizedProductContext' import type { Binding, Locale } from './types' /** @@ -74,6 +75,43 @@ export function resolveBinding( return matches.find((b) => b.isDefault) ?? matches[0] } +/** + * Resolves which product slug the locale selector should navigate to. + * + * Falls back to the default locale's slug rather than to any other entry, so a + * translated slug is never carried across locales (e.g. an Italian slug served + * under an es-ES prefix). + * + * `otherLocales` only carries locales the catalog has a registered slug for, so + * an untranslated product has no entry at all — not even for the default + * locale. `defaultLocaleSlug` covers that gap: the API resolves it best-effort + * so navigation lands on the product page instead of the locale root. + * + * @param otherLocales - Registered localized slugs for the current product + * @param targetLocale - Locale the shopper is switching to + * @param defaultLocale - The store's default locale + * @param defaultLocaleSlug - Best-effort default-locale slug, PDP only + * @returns The slug to redirect to, or null when none is available + */ +export function resolveTargetSlug({ + otherLocales, + targetLocale, + defaultLocale, + defaultLocaleSlug, +}: { + otherLocales: LocalizedProductLocale[] | null | undefined + targetLocale: string + defaultLocale: string + defaultLocaleSlug?: string | null +}): string | null { + return ( + otherLocales?.find((e) => e.locale === targetLocale)?.slug ?? + otherLocales?.find((e) => e.locale === defaultLocale)?.slug ?? + defaultLocaleSlug ?? + null + ) +} + /** * Validates that a URL is non-empty and appears to be valid. * diff --git a/packages/core/src/sdk/localization/useBindingSelector.ts b/packages/core/src/sdk/localization/useBindingSelector.ts index c5376acf8d..427247a464 100644 --- a/packages/core/src/sdk/localization/useBindingSelector.ts +++ b/packages/core/src/sdk/localization/useBindingSelector.ts @@ -8,6 +8,7 @@ import { getCurrenciesForLocale, isValidUrl, resolveBinding, + resolveTargetSlug, } from './bindingSelector' import type { LocalizedProductLocale } from './LocalizedProductContext' import type { BindingSelectorError, Locale } from './types' @@ -147,11 +148,14 @@ export interface UseBindingSelectorReturn { * 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. + * @param defaultLocaleSlug - Slug for the store's default locale, used when the + * target locale is absent from `otherLocales`. PDP only; PLPs pass nothing. * @returns Object with languages, currencies, selections, and actions */ export function useBindingSelector( otherLocales?: Array<{ locale: string; slug: string }> | null, - urlSuffix = '/p' + urlSuffix = '/p', + defaultLocaleSlug?: string | null ): UseBindingSelectorReturn { const { locale: currentLocale, currency: currentCurrency } = useSession() const localizationConfig = storeConfig.localization as LocalizationConfig @@ -284,27 +288,17 @@ export function useBindingSelector( ? 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(/\/$/, '') - globalThis.location.href = `${baseUrl}/${entry.slug}${urlSuffix}${globalThis.location.search}${globalThis.location.hash}` - return - } + const slug = resolveTargetSlug({ + otherLocales: effectiveOtherLocales, + targetLocale: localeCode, + defaultLocale: localizationConfig.defaultLocale, + defaultLocaleSlug, + }) + + if (slug) { + const baseUrl = binding.url.replace(/\/$/, '') + globalThis.location.href = `${baseUrl}/${slug}${urlSuffix}${globalThis.location.search}${globalThis.location.hash}` + return } // otherLocales is empty/null but we're still on a PDP: strip the stale slug. @@ -329,6 +323,7 @@ export function useBindingSelector( localizationConfig.defaultLocale, otherLocales, urlSuffix, + defaultLocaleSlug, ]) const isSaveEnabled = Boolean(localeCode && currencyCode && !error) diff --git a/packages/core/test/components/ui/LocalizationButton.browser.test.tsx b/packages/core/test/components/ui/LocalizationButton.browser.test.tsx new file mode 100644 index 0000000000..7b6f1c8d01 --- /dev/null +++ b/packages/core/test/components/ui/LocalizationButton.browser.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mockUseBindingSelector = vi.hoisted(() => + vi.fn(() => ({ + languages: { 'en-US': 'English' }, + currencies: { USD: 'USD' }, + localeCode: 'en-US', + currencyCode: 'USD', + setLocaleCode: vi.fn(), + setCurrencyCode: vi.fn(), + save: vi.fn(), + reset: vi.fn(), + isSaveEnabled: true, + error: null, + })) +) + +vi.mock('src/sdk/localization', () => ({ + useBindingSelector: mockUseBindingSelector, +})) + +vi.mock('src/sdk/session', () => ({ + useSession: () => ({ locale: 'pt-BR', currency: { code: 'BRL' } }), +})) + +vi.mock('discovery.config', async (importOriginal) => { + const original = await importOriginal<{ default: Record }>() + + return { default: { ...original.default, localization: { enabled: true } } } +}) + +import LocalizationButton from 'src/components/ui/LocalizationButton' +import { LocalizedProductProvider } from 'src/sdk/localization/LocalizedProductContext' + +const OTHER_LOCALES = [ + { locale: 'en-US', slug: 'roshe-tenis-76' }, + { locale: 'pt-BR', slug: 'tenis-roshe-76' }, +] + +afterEach(() => { + cleanup() + mockUseBindingSelector.mockClear() +}) + +describe('LocalizationButton', () => { + it('forwards both slug sources from the product context to the selector', () => { + render( + + + + ) + + expect(mockUseBindingSelector).toHaveBeenCalledWith( + OTHER_LOCALES, + '/p', + 'roshe-tenis-76' + ) + }) + + it('passes no default-locale slug on collection pages', () => { + render( + + + + ) + + expect(mockUseBindingSelector).toHaveBeenCalledWith( + OTHER_LOCALES, + '', + undefined + ) + }) + + it('falls back to product-page defaults outside a provider', () => { + render() + + expect(mockUseBindingSelector).toHaveBeenCalledWith( + undefined, + '/p', + undefined + ) + }) +}) diff --git a/packages/core/test/sdk/localization/useBindingSelector.browser.test.tsx b/packages/core/test/sdk/localization/useBindingSelector.browser.test.tsx new file mode 100644 index 0000000000..5d7cd558cc --- /dev/null +++ b/packages/core/test/sdk/localization/useBindingSelector.browser.test.tsx @@ -0,0 +1,252 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock(import('../../../discovery.config.js'), async (original) => ({ + default: { + ...((await original()).default ?? (await original())), + localization: { + enabled: true, + defaultLocale: 'en-US', + regions: { + US: { + code: 'US', + name: 'United States', + dateFormat: 'MM/DD/YYYY', + timeFormat: '12h', + timeFormatMask: 'hh:mm a', + unitSystem: 'imperial', + defaultTimezone: 'GMT-5', + }, + BR: { + code: 'BR', + name: 'Brazil', + dateFormat: 'DD/MM/YYYY', + timeFormat: '24h', + timeFormatMask: 'HH:mm', + unitSystem: 'metric', + defaultTimezone: 'GMT-3', + }, + IT: { + code: 'IT', + name: 'Italy', + dateFormat: 'DD/MM/YYYY', + timeFormat: '24h', + timeFormatMask: 'HH:mm', + unitSystem: 'metric', + defaultTimezone: 'GMT+1', + }, + }, + locales: { + 'en-US': { + code: 'en-US', + name: 'English', + languageCode: 'en', + languageName: 'English', + script: 'Latn', + textDirection: 'ltr', + regionCode: 'US', + bindings: [ + { + currencyCode: 'USD', + url: 'https://store.example.com', + salesChannel: '1', + isDefault: true, + }, + ], + }, + 'pt-BR': { + code: 'pt-BR', + name: 'português', + languageCode: 'pt', + languageName: 'Portuguese', + script: 'Latn', + textDirection: 'ltr', + regionCode: 'BR', + bindings: [ + { + currencyCode: 'BRL', + url: 'https://store.example.com/pt-BR', + salesChannel: '2', + isDefault: true, + }, + ], + }, + 'it-IT': { + code: 'it-IT', + name: 'italiano', + languageCode: 'it', + languageName: 'Italian', + script: 'Latn', + textDirection: 'ltr', + regionCode: 'IT', + bindings: [ + { + currencyCode: 'EUR', + url: 'https://store.example.com/it-IT', + salesChannel: '3', + isDefault: true, + }, + ], + }, + }, + currencies: { + USD: { code: 'USD', name: 'US Dollar', symbol: '$' }, + BRL: { code: 'BRL', name: 'Brazilian Real', symbol: 'R$' }, + EUR: { code: 'EUR', name: 'Euro', symbol: '€' }, + }, + }, + }, +})) + +vi.mock('../../../src/sdk/session', () => ({ + useSession: () => ({ + locale: 'en-US', + currency: { code: 'USD', symbol: '$' }, + }), +})) + +import { useBindingSelector } from '../../../src/sdk/localization/useBindingSelector' + +const TRANSLATED = [ + { locale: 'en-US', slug: 'roshe-tenis-76' }, + { locale: 'pt-BR', slug: 'tenis-roshe-76' }, +] + +/** + * Replaces `globalThis.location` with a plain object so the redirect can be + * asserted. jsdom throws on a real `href` assignment. + */ +function stubLocation(pathname: string, search = '', hash = '') { + const location = { pathname, search, hash, href: '' } + vi.stubGlobal('location', location) + + return location +} + +/** Drives the selector to a locale/currency pair and triggers the redirect. */ +function switchTo( + locale: string, + currency: string, + otherLocales?: Array<{ locale: string; slug: string }> | null, + defaultLocaleSlug?: string | null, + urlSuffix = '/p' +) { + const { result } = renderHook(() => + useBindingSelector(otherLocales, urlSuffix, defaultLocaleSlug) + ) + + act(() => result.current.setLocaleCode(locale)) + act(() => result.current.setCurrencyCode(currency)) + act(() => result.current.save()) + + return result +} + +describe('useBindingSelector redirect', () => { + beforeEach(() => { + globalThis.sessionStorage.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('navigates to the slug the catalog registered for the target locale', () => { + const location = stubLocation('/roshe-tenis-76/p') + + switchTo('pt-BR', 'BRL', TRANSLATED) + + expect(location.href).toBe( + 'https://store.example.com/pt-BR/tenis-roshe-76/p' + ) + }) + + it('falls back to the default locale slug registered in the map when the target has none', () => { + const location = stubLocation('/tenis-roshe-76/p') + + switchTo('it-IT', 'EUR', TRANSLATED) + + expect(location.href).toBe( + 'https://store.example.com/it-IT/roshe-tenis-76/p' + ) + }) + + it('navigates to the product page via defaultLocaleSlug when the target locale has no registered slug', () => { + const location = stubLocation('/roshe-tenis-76/p') + + switchTo( + 'it-IT', + 'EUR', + [{ locale: 'pt-BR', slug: 'tenis-roshe-76' }], + 'roshe-tenis-76' + ) + + // The Italian binding with the default-locale slug: an untranslated product + // page, which the agreed behavior prefers over the locale root. + expect(location.href).toBe( + 'https://store.example.com/it-IT/roshe-tenis-76/p' + ) + }) + + it('uses defaultLocaleSlug for a product with no registered translations at all', () => { + const location = stubLocation('/side-by-side-refrigerator-14/p') + + switchTo('pt-BR', 'BRL', [], 'side-by-side-refrigerator-14') + + expect(location.href).toBe( + 'https://store.example.com/pt-BR/side-by-side-refrigerator-14/p' + ) + }) + + it('never carries a slug registered for a third locale into the target locale', () => { + const location = stubLocation('/roshe-tenis-76/p') + + switchTo( + 'it-IT', + 'EUR', + [{ locale: 'pt-BR', slug: 'tenis-roshe-76' }], + 'roshe-tenis-76' + ) + + expect(location.href).not.toContain('tenis-roshe-76') + }) + + it('falls back to the binding root on a PDP with no slug available', () => { + const location = stubLocation('/roshe-tenis-76/p') + + switchTo('pt-BR', 'BRL', [], null) + + expect(location.href).toBe('https://store.example.com/pt-BR') + }) + + it('preserves the query string and hash across the switch', () => { + const location = stubLocation('/roshe-tenis-76/p', '?skuId=76', '#reviews') + + switchTo('pt-BR', 'BRL', TRANSLATED) + + expect(location.href).toBe( + 'https://store.example.com/pt-BR/tenis-roshe-76/p?skuId=76#reviews' + ) + }) + + it('omits the /p suffix for collection pages', () => { + const location = stubLocation('/apparel') + + switchTo('pt-BR', 'BRL', [{ locale: 'pt-BR', slug: 'vestuario' }], null, '') + + expect(location.href).toBe('https://store.example.com/pt-BR/vestuario') + }) + + it('reports an error instead of redirecting when the locale has no binding for the currency', () => { + const location = stubLocation('/roshe-tenis-76/p') + + const result = switchTo('pt-BR', 'JPY', TRANSLATED) + + expect(result.current.error).toEqual({ + type: 'no-binding-found', + locale: 'pt-BR', + currency: 'JPY', + }) + expect(location.href).toBe('') + }) +})