diff --git a/.changeset/trac-421-custom-locale-subfolders.md b/.changeset/trac-421-custom-locale-subfolders.md new file mode 100644 index 0000000000..343f119c9a --- /dev/null +++ b/.changeset/trac-421-custom-locale-subfolders.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Consume merchant-configured per-locale URL subfolders from the BigCommerce Storefront GraphQL API (`Locale.path`). When the Control Panel has any custom subfolder set, Catalyst trusts the CP mapping for every locale (including the default) and routes URLs using those prefixes. When no custom subfolders are configured, Catalyst keeps the legacy behavior: the default locale has no prefix and other locales use their locale code. diff --git a/core/build-config/schema.ts b/core/build-config/schema.ts index a2c802d0c7..710d49148c 100644 --- a/core/build-config/schema.ts +++ b/core/build-config/schema.ts @@ -5,6 +5,7 @@ export const buildConfigSchema = z.object({ z.object({ code: z.string(), isDefault: z.boolean(), + path: z.string().nullable(), }), ), urls: z.object({ diff --git a/core/i18n/locales.ts b/core/i18n/locales.ts index b9cde9ccb9..575d0c951c 100644 --- a/core/i18n/locales.ts +++ b/core/i18n/locales.ts @@ -4,3 +4,11 @@ const localeNodes = buildConfig.get('locales'); export const locales = localeNodes.map((locale) => locale.code); export const defaultLocale = localeNodes.find((locale) => locale.isDefault)?.code ?? 'en'; + +export const prefixes = localeNodes.reduce>((acc, locale) => { + if (locale.path) acc[locale.code] = `/${locale.path}`; + + return acc; +}, {}); + +export const hasCustomSubfolders = Object.keys(prefixes).length > 0; diff --git a/core/i18n/routing.ts b/core/i18n/routing.ts index 60517b1884..d72808b464 100644 --- a/core/i18n/routing.ts +++ b/core/i18n/routing.ts @@ -1,17 +1,22 @@ import { createNavigation } from 'next-intl/navigation'; import { defineRouting } from 'next-intl/routing'; -import { defaultLocale, locales } from './locales'; +import { defaultLocale, hasCustomSubfolders, locales, prefixes } from './locales'; enum LocalePrefixes { ALWAYS = 'always', - // Don't use NEVER as there is a issue that causes cache problems and returns the wrong messages. + // Don't use NEVER as there is an issue that causes cache problems and returns the wrong messages. // More info: https://github.com/amannn/next-intl/issues/786 // NEVER = 'never', ASNEEDED = 'as-needed', // removes prefix on default locale } -const localePrefix = LocalePrefixes.ASNEEDED; +// When the merchant has configured any locale subfolders in the BC control panel, +// trust the CP mapping for every locale (including the default). Otherwise fall back +// to the legacy behavior where the default locale has no prefix. +const localePrefix = hasCustomSubfolders + ? { mode: LocalePrefixes.ALWAYS, prefixes } + : LocalePrefixes.ASNEEDED; export const routing = defineRouting({ locales, diff --git a/core/lib/seo/canonical.ts b/core/lib/seo/canonical.ts index dd1573947a..0e2f3f1dc9 100644 --- a/core/lib/seo/canonical.ts +++ b/core/lib/seo/canonical.ts @@ -3,7 +3,7 @@ import { cache } from 'react'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; -import { defaultLocale, locales } from '~/i18n/locales'; +import { defaultLocale, hasCustomSubfolders, locales, prefixes } from '~/i18n/locales'; interface CanonicalUrlOptions { /** @@ -90,7 +90,10 @@ function buildLocalizedUrl(baseUrl: string, pathname: string, locale: string): s const url = new URL(pathname, baseUrl); - url.pathname = locale === defaultLocale ? url.pathname : `/${locale}${url.pathname}`; + const prefix = prefixes[locale] ?? `/${locale}`; + const skipPrefix = !hasCustomSubfolders && locale === defaultLocale; + + url.pathname = skipPrefix ? url.pathname : `${prefix}${url.pathname}`; if (trailingSlash && !url.pathname.endsWith('/')) { url.pathname += '/'; diff --git a/core/next.config.ts b/core/next.config.ts index 1f39548d60..afe17e2eb8 100644 --- a/core/next.config.ts +++ b/core/next.config.ts @@ -25,6 +25,7 @@ const SettingsQuery = graphql(` locales { code isDefault + path } } } diff --git a/core/proxies/with-routes.ts b/core/proxies/with-routes.ts index 2be0083849..f8a0dcbae7 100644 --- a/core/proxies/with-routes.ts +++ b/core/proxies/with-routes.ts @@ -5,6 +5,7 @@ import { auth } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; +import { prefixes } from '~/i18n/locales'; import { getVisitIdCookie, getVisitorIdCookie } from '~/lib/analytics/bigcommerce'; import { sendProductViewedEvent } from '~/lib/analytics/bigcommerce/data-events'; import { kvKey, STORE_STATUS_KEY } from '~/lib/kv/keys'; @@ -218,12 +219,14 @@ const updateStatusCache = async ( }; const clearLocaleFromPath = (path: string, locale: string) => { - if (path === `/${locale}` || path === `/${locale}/`) { + const prefix = prefixes[locale] ?? `/${locale}`; + + if (path === prefix || path === `${prefix}/`) { return '/'; } - if (path.startsWith(`/${locale}/`)) { - return path.replace(`/${locale}`, ''); + if (path.startsWith(`${prefix}/`)) { + return path.replace(prefix, ''); } return path;