@faststore/core 4.5.0-dev.3 → 4.5.0-dev.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.5.0-dev.3",
3
+ "version": "4.5.0-dev.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -71,11 +71,11 @@
71
71
  "style-loader": "^3.3.1",
72
72
  "swr": "^2.2.5",
73
73
  "use-sync-external-store": "^1.6.0",
74
- "@faststore/diagnostics": "4.5.0-dev.3",
75
- "@faststore/lighthouse": "4.5.0-dev.3",
76
- "@faststore/sdk": "4.5.0-dev.3",
77
- "@faststore/api": "4.5.0-dev.3",
78
- "@faststore/ui": "4.5.0-dev.3"
74
+ "@faststore/lighthouse": "4.5.0-dev.4",
75
+ "@faststore/api": "4.5.0-dev.4",
76
+ "@faststore/sdk": "4.5.0-dev.4",
77
+ "@faststore/ui": "4.5.0-dev.4",
78
+ "@faststore/diagnostics": "4.5.0-dev.4"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@cypress/code-coverage": "^3.12.1",
@@ -1,5 +1,5 @@
1
- import type { PropsWithChildren } from 'react'
2
1
  import { useRouter } from 'next/router'
2
+ import type { PropsWithChildren } from 'react'
3
3
 
4
4
  import { Icon as UIIcon, Loader as UILoader } from '@faststore/ui'
5
5
 
@@ -9,8 +9,8 @@ import Section from '../Section'
9
9
 
10
10
  import styles from './section.module.scss'
11
11
 
12
- import { EmptyStateDefaultComponents } from './DefaultComponents'
13
12
  import { getOverridableSection } from '../../../sdk/overrides/getOverriddenSection'
13
+ import { EmptyStateDefaultComponents } from './DefaultComponents'
14
14
 
15
15
  export interface EmptyStateProps {
16
16
  /**
@@ -53,11 +53,12 @@ const useErrorState = () => {
53
53
  query: { errorId, fromUrl },
54
54
  pathname,
55
55
  asPath,
56
+ locale,
56
57
  } = router
57
58
 
58
59
  return {
59
60
  errorId,
60
- fromUrl: fromUrl ?? asPath ?? pathname,
61
+ fromUrl: fromUrl ?? (locale ? `/${locale}${asPath}` : asPath) ?? pathname,
61
62
  }
62
63
  }
63
64
 
@@ -21,6 +21,7 @@ import type { PLPContentType } from 'src/server/cms/plp'
21
21
 
22
22
  import storeConfig from '../../../../discovery.config'
23
23
  import { faststoreLoader } from 'src/components/ui/Image/loader'
24
+ import { LocalizedProductProvider } from 'src/sdk/localization/LocalizedProductContext'
24
25
  import ProductListing from './ProductListing'
25
26
  import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
26
27
 
@@ -142,43 +143,48 @@ export default function ProductListingPage({
142
143
  : undefined
143
144
 
144
145
  return (
145
- <SearchProvider
146
- onChange={applySearchState}
147
- itemsPerPage={itemsPerPage}
148
- shouldResetInfiniteScroll={!storeConfig.experimental?.scrollRestoration}
149
- {...searchParams}
146
+ <LocalizedProductProvider
147
+ otherLocales={server.collection?.otherLocales}
148
+ urlSuffix=""
150
149
  >
151
- {lcpImageUrl && (
152
- <Head>
153
- <link
154
- rel="preload"
155
- as="image"
156
- href={lcpImageUrl}
157
- fetchPriority="high"
158
- />
159
- </Head>
160
- )}
161
- {/* SEO */}
162
- <NextSeo
163
- title={title}
164
- description={description}
165
- titleTemplate={titleTemplate}
166
- canonical={canonical}
167
- openGraph={{
168
- type: 'website',
169
- title,
170
- description,
171
- }}
172
- />
173
- <BreadcrumbJsonLd itemListElements={itemListElements} />
174
-
175
- <ProductListing
176
- globalSections={globalSections}
177
- globalSettings={globalSettings}
178
- page={plpContentType}
179
- data={server}
180
- serverManyProductsVariables={serverManyProductsVariables}
181
- />
182
- </SearchProvider>
150
+ <SearchProvider
151
+ onChange={applySearchState}
152
+ itemsPerPage={itemsPerPage}
153
+ shouldResetInfiniteScroll={!storeConfig.experimental?.scrollRestoration}
154
+ {...searchParams}
155
+ >
156
+ {lcpImageUrl && (
157
+ <Head>
158
+ <link
159
+ rel="preload"
160
+ as="image"
161
+ href={lcpImageUrl}
162
+ fetchPriority="high"
163
+ />
164
+ </Head>
165
+ )}
166
+ {/* SEO */}
167
+ <NextSeo
168
+ title={title}
169
+ description={description}
170
+ titleTemplate={titleTemplate}
171
+ canonical={canonical}
172
+ openGraph={{
173
+ type: 'website',
174
+ title,
175
+ description,
176
+ }}
177
+ />
178
+ <BreadcrumbJsonLd itemListElements={itemListElements} />
179
+
180
+ <ProductListing
181
+ globalSections={globalSections}
182
+ globalSettings={globalSettings}
183
+ page={plpContentType}
184
+ data={server}
185
+ serverManyProductsVariables={serverManyProductsVariables}
186
+ />
187
+ </SearchProvider>
188
+ </LocalizedProductProvider>
183
189
  )
184
190
  }
@@ -2,7 +2,6 @@ import type { BreadcrumbProps as UIBreadcrumbProps } from '@faststore/ui'
2
2
  import { Breadcrumb as UIBreadcrumb } from '@faststore/ui'
3
3
  import { memo } from 'react'
4
4
 
5
- import storeConfig from 'discovery.config'
6
5
  import Link from 'src/components/ui/Link'
7
6
  import { useOverrideComponents } from 'src/sdk/overrides/OverrideContext'
8
7
  export interface BreadcrumbProps extends UIBreadcrumbProps {
@@ -32,17 +31,11 @@ const Breadcrumb = ({ icon, alt, ...otherProps }: BreadcrumbProps) => {
32
31
  />
33
32
  </Link>
34
33
  }
35
- renderLink={({ itemProps: { item: link, name } }) =>
36
- // workaround to display the name without a link since localized slug is not available yet
37
- // TODO: when localized slugs are available remove this workaround
38
- storeConfig.localization?.enabled ? (
39
- <span data-fs-breadcrumb-link>{name}</span>
40
- ) : (
41
- <Link data-fs-breadcrumb-link href={link} prefetch={false}>
42
- {name}
43
- </Link>
44
- )
45
- }
34
+ renderLink={({ itemProps: { item: link, name } }) => (
35
+ <Link data-fs-breadcrumb-link href={link} prefetch={false}>
36
+ {name}
37
+ </Link>
38
+ )}
46
39
  {...Breadcrumb.props}
47
40
  {...otherProps}
48
41
  />
@@ -6,6 +6,7 @@ import { Icon, Button as UIButton } from '@faststore/ui'
6
6
  import storeConfig from 'discovery.config'
7
7
  import LocalizationSelector from 'src/components/localization/LocalizationSelector'
8
8
  import { useBindingSelector } from 'src/sdk/localization'
9
+ import { useLocalizedProduct } from 'src/sdk/localization/LocalizedProductContext'
9
10
  import { useSession } from 'src/sdk/session'
10
11
 
11
12
  export interface LocalizationButtonErrorMessages {
@@ -41,6 +42,9 @@ const LocalizationButton = ({
41
42
  const [isSelectorOpen, setIsSelectorOpen] = useState(false)
42
43
  const buttonRef = useRef<HTMLButtonElement>(null)
43
44
 
45
+ const otherLocales = useLocalizedProduct()?.otherLocales ?? undefined
46
+ const urlSuffix = useLocalizedProduct()?.urlSuffix ?? '/p'
47
+
44
48
  const {
45
49
  languages,
46
50
  currencies,
@@ -52,7 +56,7 @@ const LocalizationButton = ({
52
56
  reset,
53
57
  isSaveEnabled,
54
58
  error,
55
- } = useBindingSelector()
59
+ } = useBindingSelector(otherLocales, urlSuffix)
56
60
 
57
61
  const { locale: sessionLocale, currency: sessionCurrency } = useSession()
58
62
 
@@ -82,3 +82,5 @@ export type { UseBindingSelectorReturn } from '../sdk/localization/useBindingSel
82
82
  export { useLink as useLink_unstable } from '../sdk/ui/useLink'
83
83
  export { default as Link_unstable } from '../components/ui/Link'
84
84
  export { localizeRedirectDestination as localizeRedirectDestination_unstable } from '../utils/localization/localizeRedirectDestination'
85
+ export { useLocalizedProduct as useLocalizedProduct_unstable } from '../sdk/localization/LocalizedProductContext'
86
+ export type { LocalizedProductLocale as LocalizedProductLocale_unstable } from '../sdk/localization/LocalizedProductContext'
@@ -106,6 +106,10 @@ const query = gql(`
106
106
  value
107
107
  }
108
108
  }
109
+ otherLocales {
110
+ locale
111
+ slug
112
+ }
109
113
  }
110
114
  }
111
115
  `)
@@ -172,6 +176,7 @@ export const getStaticProps: GetStaticProps<
172
176
  >({
173
177
  variables: { slug },
174
178
  operation: query,
179
+ locale,
175
180
  }),
176
181
  contentService.getPlpContent(
177
182
  {
@@ -34,6 +34,7 @@ import {
34
34
  getGlobalSectionsData,
35
35
  type GlobalSectionsData,
36
36
  } from 'src/components/cms/GlobalSections'
37
+ import { LocalizedProductProvider } from 'src/sdk/localization/LocalizedProductContext'
37
38
  import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
38
39
  import { getOfferUrl, useOffer } from 'src/sdk/offer'
39
40
  import PageProvider, { type PDPContext } from 'src/sdk/overrides/PageProvider'
@@ -94,6 +95,46 @@ const overwriteMerge = (_: any[], sourceArray: any[]) => sourceArray
94
95
  const isClientOfferEnabled = (storeConfig as StoreConfig).experimental
95
96
  .enableClientOffer
96
97
 
98
+ function buildHreflangLinks(
99
+ storeConfig: StoreConfig,
100
+ otherLocales: Array<{ locale: string; slug: string }> | null | undefined
101
+ ): Array<{ rel: string; hrefLang: string; href: string }> {
102
+ if (!storeConfig.localization?.enabled || !otherLocales?.length) return []
103
+
104
+ const locales = storeConfig.localization.locales
105
+ const baseStoreUrl = storeConfig.storeUrl.replace(/\/$/, '')
106
+ const defaultLocale = storeConfig.localization.defaultLocale
107
+ const links: Array<{ rel: string; hrefLang: string; href: string }> = []
108
+
109
+ for (const { locale, slug } of otherLocales) {
110
+ const bindingUrl = locales?.[locale]?.bindings?.[0]?.url
111
+ if (typeof bindingUrl === 'string' && bindingUrl.length > 0) {
112
+ links.push({
113
+ rel: 'alternate',
114
+ hrefLang: locale,
115
+ href: `${bindingUrl.replace(/\/$/, '')}/${slug}/p`,
116
+ })
117
+ }
118
+ }
119
+
120
+ // PREMISE: the default locale is served at the store root (no locale prefix),
121
+ // which is how Next.js i18n sub-path routing works — the `defaultLocale` has no
122
+ // prefix while other locales live under `/{locale}`. Hence `x-default` points to
123
+ // `${storeUrl}/{slug}/p`. If a store ever serves its default locale under a path
124
+ // prefix or a dedicated domain instead of the root, this href must be derived
125
+ // from that locale's binding URL instead of `storeUrl`.
126
+ const defaultEntry = otherLocales.find((e) => e.locale === defaultLocale)
127
+ if (defaultEntry) {
128
+ links.push({
129
+ rel: 'alternate',
130
+ hrefLang: 'x-default',
131
+ href: `${baseStoreUrl}/${defaultEntry.slug}/p`,
132
+ })
133
+ }
134
+
135
+ return links
136
+ }
137
+
97
138
  function Page({
98
139
  data: server,
99
140
  sections,
@@ -136,6 +177,12 @@ function Page({
136
177
  product.offers.offers[0]?.itemCondition ?? ''
137
178
  ]
138
179
 
180
+ // hreflang alternate links for multi-locale stores
181
+ const hreflangLinks = buildHreflangLinks(
182
+ storeConfig as StoreConfig,
183
+ server.product.otherLocales
184
+ )
185
+
139
186
  let itemListElements = product.breadcrumbList.itemListElement ?? []
140
187
  if (itemListElements.length !== 0) {
141
188
  itemListElements = itemListElements.map(
@@ -245,13 +292,11 @@ function Page({
245
292
  ]
246
293
  : []),
247
294
  ]}
295
+ additionalLinkTags={hreflangLinks}
248
296
  titleTemplate={titleTemplate}
249
297
  />
250
298
 
251
- {/* TODO: when localized slugs are available remove this workaround */}
252
- {!storeConfig.localization?.enabled && (
253
- <BreadcrumbJsonLd itemListElements={itemListElements} />
254
- )}
299
+ <BreadcrumbJsonLd itemListElements={itemListElements} />
255
300
 
256
301
  <ProductJsonLd
257
302
  id={`${meta.canonical}${settings?.seo?.id ?? ''}`}
@@ -283,13 +328,15 @@ function Page({
283
328
  If needed, wrap your component in a <Section /> component
284
329
  (not the HTML tag) before rendering it here.
285
330
  */}
286
- <PageProvider context={context}>
287
- <RenderSections
288
- sections={sections}
289
- globalSections={globalSections}
290
- components={COMPONENTS}
291
- />
292
- </PageProvider>
331
+ <LocalizedProductProvider otherLocales={server.product.otherLocales}>
332
+ <PageProvider context={context}>
333
+ <RenderSections
334
+ sections={sections}
335
+ globalSections={globalSections}
336
+ components={COMPONENTS}
337
+ />
338
+ </PageProvider>
339
+ </LocalizedProductProvider>
293
340
  </>
294
341
  )
295
342
  }
@@ -351,6 +398,11 @@ const query = gql(`
351
398
  productGroupID
352
399
  }
353
400
 
401
+ otherLocales {
402
+ locale
403
+ slug
404
+ }
405
+
354
406
  ...ProductDetailsFragment_product
355
407
  }
356
408
  }
@@ -0,0 +1,65 @@
1
+ import { createContext, useContext, useMemo } from 'react'
2
+ import type { PropsWithChildren } from 'react'
3
+
4
+ export interface LocalizedProductLocale {
5
+ locale: string
6
+ slug: string
7
+ }
8
+
9
+ interface LocalizedProductData {
10
+ /**
11
+ * Localized slug entries for all available locales of the current page.
12
+ * Null when localization is disabled or the page type does not support it.
13
+ */
14
+ otherLocales: LocalizedProductLocale[] | null
15
+ /**
16
+ * Suffix appended after the localized slug when building the redirect URL.
17
+ * Use '/p' for product pages (PDP), '' for collection/PLP pages.
18
+ */
19
+ urlSuffix: string
20
+ }
21
+
22
+ const LocalizedProductContext = createContext<LocalizedProductData | null>(null)
23
+
24
+ type LocalizedProductProviderProps = Readonly<
25
+ PropsWithChildren<{
26
+ otherLocales: LocalizedProductLocale[] | null | undefined
27
+ /**
28
+ * Suffix to append to the localized slug when building the redirect URL.
29
+ * Defaults to '/p' (product pages). Pass '' for collection/PLP pages.
30
+ */
31
+ urlSuffix?: string
32
+ }>
33
+ >
34
+
35
+ /**
36
+ * Provides localized page data (otherLocales, urlSuffix) to any component in
37
+ * the tree — including global components like LocalizationButton that are
38
+ * not co-located with page sections.
39
+ *
40
+ * Set in p.tsx (PDP) and [...slug].tsx (PLP); returns null outside those pages.
41
+ */
42
+ export function LocalizedProductProvider({
43
+ otherLocales,
44
+ urlSuffix = '/p',
45
+ children,
46
+ }: LocalizedProductProviderProps) {
47
+ const value = useMemo(
48
+ () => ({ otherLocales: otherLocales ?? null, urlSuffix }),
49
+ [otherLocales, urlSuffix]
50
+ )
51
+
52
+ return (
53
+ <LocalizedProductContext.Provider value={value}>
54
+ {children}
55
+ </LocalizedProductContext.Provider>
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Returns localized product data for the current page.
61
+ * Safe to call from any component — returns null outside a product page.
62
+ */
63
+ export function useLocalizedProduct(): LocalizedProductData | null {
64
+ return useContext(LocalizedProductContext)
65
+ }
@@ -9,8 +9,86 @@ import {
9
9
  isValidUrl,
10
10
  resolveBinding,
11
11
  } from './bindingSelector'
12
+ import type { LocalizedProductLocale } from './LocalizedProductContext'
12
13
  import type { BindingSelectorError, Locale } from './types'
13
14
 
15
+ const OTHER_LOCALES_STORAGE_PREFIX = 'fs:otherLocales:'
16
+
17
+ /**
18
+ * Extracts the SKU id from a PDP-shaped pathname ("/.../{slug}-{skuId}/p").
19
+ * Slugs always end with "-{skuId}" (see `getSlug` in the API), so the id is the
20
+ * last "-"-separated segment of the path segment that precedes "/p".
21
+ * Returns null when the path is not a product page or has no numeric id.
22
+ */
23
+ export function getSkuIdFromPdpPath(pathname: string): string | null {
24
+ const match = pathname.match(/\/([^/]+)\/p\/?$/) // NOSONAR
25
+ const skuId = match?.[1]?.split('-').pop()
26
+
27
+ return skuId && /^\d+$/.test(skuId) ? skuId : null
28
+ }
29
+
30
+ /**
31
+ * Persists the localized slug map for a product keyed by its SKU id. This lets
32
+ * the locale selector rebuild canonical localized product URLs even after the
33
+ * user lands on a context-less page (e.g. a 404 for a locale where the product
34
+ * is unavailable), where `otherLocales` is no longer provided by the page.
35
+ */
36
+ export function persistOtherLocales(
37
+ otherLocales: LocalizedProductLocale[]
38
+ ): void {
39
+ if (typeof window === 'undefined' || !otherLocales.length) return
40
+
41
+ const skuId = otherLocales[0]?.slug.split('-').pop()
42
+ if (!skuId) return
43
+
44
+ try {
45
+ globalThis.sessionStorage.setItem(
46
+ `${OTHER_LOCALES_STORAGE_PREFIX}${skuId}`,
47
+ JSON.stringify(otherLocales)
48
+ )
49
+ } catch {
50
+ // sessionStorage may be unavailable (private mode/quota); non-critical.
51
+ }
52
+ }
53
+
54
+ function isLocalizedProductLocaleArray(
55
+ value: unknown
56
+ ): value is LocalizedProductLocale[] {
57
+ return (
58
+ Array.isArray(value) &&
59
+ value.every(
60
+ (item) =>
61
+ typeof item === 'object' &&
62
+ item !== null &&
63
+ typeof (item as Record<string, unknown>).locale === 'string' &&
64
+ typeof (item as Record<string, unknown>).slug === 'string'
65
+ )
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Recovers a previously persisted localized slug map for the product referenced
71
+ * by the current PDP URL. Returns null when not on a PDP or nothing is stored.
72
+ */
73
+ export function recoverOtherLocales(): LocalizedProductLocale[] | null {
74
+ if (typeof window === 'undefined') return null
75
+
76
+ const skuId = getSkuIdFromPdpPath(globalThis.location.pathname)
77
+ if (!skuId) return null
78
+
79
+ try {
80
+ const raw = globalThis.sessionStorage.getItem(
81
+ `${OTHER_LOCALES_STORAGE_PREFIX}${skuId}`
82
+ )
83
+
84
+ if (!raw) return null
85
+ const parsed: unknown = JSON.parse(raw)
86
+ return isLocalizedProductLocaleArray(parsed) ? parsed : null
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
14
92
  interface Currency {
15
93
  code: string
16
94
  name: string
@@ -64,9 +142,17 @@ export interface UseBindingSelectorReturn {
64
142
  * Hook that provides state and actions for the localization selector.
65
143
  * Manages locale selection, currency filtering, and binding resolution.
66
144
  *
145
+ * @param otherLocales - Optional list of localized slugs for the current page.
146
+ * When provided (e.g. on PDP or PLP), the save action navigates to the
147
+ * localized page URL instead of preserving the current page path verbatim.
148
+ * @param urlSuffix - Suffix appended after the slug when building the redirect URL.
149
+ * Use '/p' for product pages (default) and '' for collection/PLP pages.
67
150
  * @returns Object with languages, currencies, selections, and actions
68
151
  */
69
- export function useBindingSelector(): UseBindingSelectorReturn {
152
+ export function useBindingSelector(
153
+ otherLocales?: Array<{ locale: string; slug: string }> | null,
154
+ urlSuffix = '/p'
155
+ ): UseBindingSelectorReturn {
70
156
  const { locale: currentLocale, currency: currentCurrency } = useSession()
71
157
  const localizationConfig = storeConfig.localization as LocalizationConfig
72
158
 
@@ -96,6 +182,15 @@ export function useBindingSelector(): UseBindingSelectorReturn {
96
182
  }
97
183
  }, [currentCurrency?.code])
98
184
 
185
+ // Persist the product's localized slugs so a later locale switch can rebuild
186
+ // the canonical localized URL even from a context-less page (e.g. a 404).
187
+ // Only relevant on PDPs — PLP otherLocales are collection slugs, not products.
188
+ useEffect(() => {
189
+ if (urlSuffix === '/p' && otherLocales?.length) {
190
+ persistOtherLocales(otherLocales)
191
+ }
192
+ }, [otherLocales, urlSuffix])
193
+
99
194
  // Build language options with disambiguation - returns Record<localeCode, languageName>
100
195
  const languages = useMemo(
101
196
  () => buildLanguageOptions(localizationConfig.locales),
@@ -181,12 +276,60 @@ export function useBindingSelector(): UseBindingSelectorReturn {
181
276
  return
182
277
  }
183
278
 
184
- // Redirect to binding URL, preserving the current page path and query string
185
- window.location.href = buildRedirectUrl(
279
+ // Prefer the current page's localized slugs. When missing (e.g. we're on a
280
+ // 404 for a locale where the product is unavailable) recover the map that was
281
+ // persisted while on a working PDP, so we can still build the canonical
282
+ // localized product URL instead of dropping the user on the locale home.
283
+ const effectiveOtherLocales = otherLocales?.length
284
+ ? otherLocales
285
+ : recoverOtherLocales()
286
+
287
+ if (effectiveOtherLocales?.length) {
288
+ // 1. Target locale has a specific translation → use it
289
+ const localizedEntry = effectiveOtherLocales.find(
290
+ (e) => e.locale === localeCode
291
+ )
292
+
293
+ // 2. No translation for target locale → fall back to the default locale slug
294
+ // (IS linkText, always in the default locale) to avoid carrying over a
295
+ // translated slug from a different locale (e.g. Italian slug on es-ES).
296
+ // For an unavailable target this yields a 404 at the product URL (expected).
297
+ const fallbackEntry = effectiveOtherLocales.find(
298
+ (e) => e.locale === localizationConfig.defaultLocale
299
+ )
300
+
301
+ const entry = localizedEntry ?? fallbackEntry
302
+
303
+ if (entry) {
304
+ const baseUrl = binding.url.replace(/\/$/, '')
305
+ globalThis.location.href = `${baseUrl}/${entry.slug}${urlSuffix}${globalThis.location.search}${globalThis.location.hash}`
306
+ return
307
+ }
308
+ }
309
+
310
+ // otherLocales is empty/null but we're still on a PDP: strip the stale slug.
311
+ if (globalThis.location.pathname.endsWith('/p')) {
312
+ globalThis.location.href = binding.url
313
+ return
314
+ }
315
+
316
+ // No localized slugs available (not even persisted): preserve the current
317
+ // page path under the target binding instead of dropping the user on the
318
+ // locale home. For a default-locale slug this resolves the product; for an
319
+ // unavailable/untranslated slug it yields a 404 at the product URL, never the
320
+ // locale root.
321
+ globalThis.location.href = buildRedirectUrl(
186
322
  binding.url,
187
- `${window.location.pathname}${window.location.search}${window.location.hash}`
323
+ `${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`
188
324
  )
189
- }, [localeCode, currencyCode, localizationConfig.locales])
325
+ }, [
326
+ localeCode,
327
+ currencyCode,
328
+ localizationConfig.locales,
329
+ localizationConfig.defaultLocale,
330
+ otherLocales,
331
+ urlSuffix,
332
+ ])
190
333
 
191
334
  const isSaveEnabled = Boolean(localeCode && currencyCode && !error)
192
335
 
@@ -34,6 +34,7 @@ interface ExecuteOptions<V = Record<string, unknown>> {
34
34
  operation: Operation
35
35
  variables: V
36
36
  query?: string | null
37
+ locale?: string
37
38
  }
38
39
 
39
40
  const persistedQueries = new Map()
@@ -98,7 +99,7 @@ export const execute = async <V extends Maybe<{ [key: string]: unknown }>, D>(
98
99
  cookies: Map<string, Record<string, string>> | null
99
100
  }
100
101
  }> => {
101
- const { operation, variables, query: maybeQuery } = options
102
+ const { operation, variables, query: maybeQuery, locale } = options
102
103
  const { operationHash, operationName } = operation['__meta__']
103
104
 
104
105
  if (!persistedQueries.size) {
@@ -127,6 +128,14 @@ export const execute = async <V extends Maybe<{ [key: string]: unknown }>, D>(
127
128
 
128
129
  const contextValue = await contextFactory(envelopContext)
129
130
 
131
+ // Override the store-default locale with the per-request locale (if any) so
132
+ // localization-aware resolvers read it from ctx.storage.locale. Resolvers that
133
+ // carry their own locale (e.g. product/search via locator facets) still
134
+ // override this default inside their own resolution.
135
+ if (locale) {
136
+ contextValue.storage.locale = locale
137
+ }
138
+
130
139
  const { data, errors } = (await run({
131
140
  schema,
132
141
  document: parse(query),