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

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/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [4.5.0-dev.5](https://github.com/vtex/faststore/compare/v4.5.0-dev.4...v4.5.0-dev.5) (2026-07-27)
7
+
8
+ ### Features
9
+
10
+ - forward Pricing Fallback price token to Checkout ([#3415](https://github.com/vtex/faststore/issues/3415)) ([bc6cda2](https://github.com/vtex/faststore/commit/bc6cda2cdaed0540896ca7202246ad2d3dcd5766))
11
+
12
+ # [4.5.0-dev.4](https://github.com/vtex/faststore/compare/v4.5.0-dev.3...v4.5.0-dev.4) (2026-07-24)
13
+
14
+ ### Features
15
+
16
+ - **core:** localized product URLs, breadcrumbs PDP and PLPs ([#3402](https://github.com/vtex/faststore/issues/3402)) ([3c6b6ef](https://github.com/vtex/faststore/commit/3c6b6ef29ef902ccae3ff784297b24e6c6bc7802)), closes [#3352](https://github.com/vtex/faststore/issues/3352) [#3401](https://github.com/vtex/faststore/issues/3401)
17
+
6
18
  # [4.5.0-dev.3](https://github.com/vtex/faststore/compare/v4.5.0-dev.2...v4.5.0-dev.3) (2026-07-22)
7
19
 
8
20
  ### Features
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.5",
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/api": "4.5.0-dev.5",
75
+ "@faststore/diagnostics": "4.5.0-dev.5",
76
+ "@faststore/sdk": "4.5.0-dev.5",
77
+ "@faststore/lighthouse": "4.5.0-dev.5",
78
+ "@faststore/ui": "4.5.0-dev.5"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@cypress/code-coverage": "^3.12.1",
@@ -234,6 +234,7 @@ export const fragment = gql(`
234
234
  listPriceWithTaxes
235
235
  priceWithTaxes
236
236
  quantity
237
+ priceToken
237
238
  seller {
238
239
  identifier
239
240
  }
@@ -82,6 +82,7 @@ function SearchProductItem({
82
82
  listPriceWithTaxes,
83
83
  seller,
84
84
  priceWithTaxes,
85
+ priceToken,
85
86
  quantity: offersQuantity,
86
87
  },
87
88
  ],
@@ -127,6 +128,7 @@ function SearchProductItem({
127
128
  listPriceWithTaxes,
128
129
  seller,
129
130
  quantity,
131
+ priceToken,
130
132
  itemOffered: {
131
133
  sku,
132
134
  name,
@@ -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
 
@@ -401,6 +401,7 @@ export const fragment = gql(`
401
401
  listPrice
402
402
  listPriceWithTaxes
403
403
  quantity
404
+ priceToken
404
405
  seller {
405
406
  identifier
406
407
  }
@@ -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
 
@@ -89,6 +89,7 @@ function ProductComparisonSidebar(props: UIProductComparisonSidebarProps) {
89
89
  listPriceWithTaxes: offer.listPriceWithTaxes,
90
90
  priceWithTaxes: offer.priceWithTaxes,
91
91
  quantity: offer.quantity,
92
+ priceToken: offer.priceToken,
92
93
  seller: { identifier: offer.seller.identifier },
93
94
  })),
94
95
  },
@@ -129,6 +130,7 @@ function ProductComparisonSidebar(props: UIProductComparisonSidebarProps) {
129
130
  seller,
130
131
  quantity,
131
132
  listPriceWithTaxes,
133
+ priceToken,
132
134
  },
133
135
  ],
134
136
  },
@@ -141,6 +143,7 @@ function ProductComparisonSidebar(props: UIProductComparisonSidebarProps) {
141
143
  listPrice,
142
144
  listPriceWithTaxes,
143
145
  seller,
146
+ priceToken,
144
147
  quantity: productIdToBuy === id ? 1 : 0,
145
148
  itemOffered: {
146
149
  sku,
@@ -226,6 +229,7 @@ export const fragment = gql(`
226
229
  listPrice
227
230
  quantity
228
231
  listPriceWithTaxes
232
+ priceToken
229
233
  seller {
230
234
  identifier
231
235
  }
@@ -79,6 +79,7 @@ function ProductDetailsSettings({
79
79
  listPrice,
80
80
  seller,
81
81
  listPriceWithTaxes,
82
+ priceToken,
82
83
  },
83
84
  ],
84
85
  },
@@ -92,6 +93,7 @@ function ProductDetailsSettings({
92
93
  listPriceWithTaxes,
93
94
  seller,
94
95
  quantity,
96
+ priceToken,
95
97
  itemOffered: {
96
98
  sku,
97
99
  name: variantName,
@@ -53,7 +53,15 @@ function SKUMatrixSidebar(props: SKUMatrixProps) {
53
53
  .map((item) => {
54
54
  const {
55
55
  offers: {
56
- offers: [{ price, priceWithTaxes, listPrice, listPriceWithTaxes }],
56
+ offers: [
57
+ {
58
+ price,
59
+ priceWithTaxes,
60
+ listPrice,
61
+ listPriceWithTaxes,
62
+ priceToken,
63
+ },
64
+ ],
57
65
  },
58
66
  } = item
59
67
 
@@ -64,6 +72,7 @@ function SKUMatrixSidebar(props: SKUMatrixProps) {
64
72
  listPrice,
65
73
  listPriceWithTaxes,
66
74
  seller,
75
+ priceToken,
67
76
  quantity: item.selectedCount,
68
77
  itemOffered: {
69
78
  sku: item.id,
@@ -131,6 +140,7 @@ export const fragment = gql(`
131
140
  itemCondition
132
141
  availability
133
142
  quantity
143
+ priceToken
134
144
  }
135
145
  }
136
146
  additionalProperty {
@@ -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,62 @@ 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
+
138
+ // With client-side offer enabled, `useOffer` only refreshes price aggregates —
139
+ // it does not rebuild the per-seller array the buy button reads. Keep that array
140
+ // from SSG, but inject the fresh Pricing Fallback token into the best offer so the
141
+ // value sent to the cart respects the token's short validity window.
142
+ const withFreshPriceToken = (
143
+ serverOffers: ServerProductQueryQuery['product']['offers'],
144
+ offer: ReturnType<typeof useOffer>
145
+ ) => ({
146
+ ...offer.offers,
147
+ offers: serverOffers.offers.map((offerItem, index) =>
148
+ index === 0
149
+ ? { ...offerItem, priceToken: offer.priceToken ?? offerItem.priceToken }
150
+ : offerItem
151
+ ),
152
+ })
153
+
97
154
  function Page({
98
155
  data: server,
99
156
  sections,
@@ -136,6 +193,12 @@ function Page({
136
193
  product.offers.offers[0]?.itemCondition ?? ''
137
194
  ]
138
195
 
196
+ // hreflang alternate links for multi-locale stores
197
+ const hreflangLinks = buildHreflangLinks(
198
+ storeConfig as StoreConfig,
199
+ server.product.otherLocales
200
+ )
201
+
139
202
  let itemListElements = product.breadcrumbList.itemListElement ?? []
140
203
  if (itemListElements.length !== 0) {
141
204
  itemListElements = itemListElements.map(
@@ -151,7 +214,11 @@ function Page({
151
214
  ? (() => {
152
215
  const offer = useOffer({ skuId: product.sku })
153
216
  return {
154
- client: { product: { offers: offer.offers } },
217
+ client: {
218
+ product: {
219
+ offers: withFreshPriceToken(product.offers, offer),
220
+ },
221
+ },
155
222
  isValidating: offer.isValidating,
156
223
  }
157
224
  })()
@@ -245,13 +312,11 @@ function Page({
245
312
  ]
246
313
  : []),
247
314
  ]}
315
+ additionalLinkTags={hreflangLinks}
248
316
  titleTemplate={titleTemplate}
249
317
  />
250
318
 
251
- {/* TODO: when localized slugs are available remove this workaround */}
252
- {!storeConfig.localization?.enabled && (
253
- <BreadcrumbJsonLd itemListElements={itemListElements} />
254
- )}
319
+ <BreadcrumbJsonLd itemListElements={itemListElements} />
255
320
 
256
321
  <ProductJsonLd
257
322
  id={`${meta.canonical}${settings?.seo?.id ?? ''}`}
@@ -283,13 +348,15 @@ function Page({
283
348
  If needed, wrap your component in a <Section /> component
284
349
  (not the HTML tag) before rendering it here.
285
350
  */}
286
- <PageProvider context={context}>
287
- <RenderSections
288
- sections={sections}
289
- globalSections={globalSections}
290
- components={COMPONENTS}
291
- />
292
- </PageProvider>
351
+ <LocalizedProductProvider otherLocales={server.product.otherLocales}>
352
+ <PageProvider context={context}>
353
+ <RenderSections
354
+ sections={sections}
355
+ globalSections={globalSections}
356
+ components={COMPONENTS}
357
+ />
358
+ </PageProvider>
359
+ </LocalizedProductProvider>
293
360
  </>
294
361
  )
295
362
  }
@@ -341,6 +408,7 @@ const query = gql(`
341
408
  priceValidUntil
342
409
  priceCurrency
343
410
  itemCondition
411
+ priceToken
344
412
  seller {
345
413
  identifier
346
414
  }
@@ -351,6 +419,11 @@ const query = gql(`
351
419
  productGroupID
352
420
  }
353
421
 
422
+ otherLocales {
423
+ locale
424
+ slug
425
+ }
426
+
354
427
  ...ProductDetailsFragment_product
355
428
  }
356
429
  }
@@ -78,8 +78,9 @@ export const useReorder = () => {
78
78
  seller: {
79
79
  identifier: item.seller,
80
80
  },
81
- price: item.price ?? 0,
82
- listPrice: item.price ?? 0,
81
+ price: item.price,
82
+ listPrice: item.price,
83
+ priceToken: null,
83
84
  itemOffered: {
84
85
  sku: item.id ?? '',
85
86
  image: item.imageUrl
@@ -111,7 +112,7 @@ export const useReorder = () => {
111
112
  orderPayload.orderNumber = currentCart.id
112
113
  }
113
114
 
114
- const { validateCart: validated } = await request<
115
+ const result = await request<
115
116
  ValidateCartMutationMutation,
116
117
  ValidateCartMutationMutationVariables
117
118
  >(ValidateCartMutation, {
@@ -121,6 +122,8 @@ export const useReorder = () => {
121
122
  },
122
123
  })
123
124
 
125
+ const validated = result?.validateCart ?? null
126
+
124
127
  if (!validated) {
125
128
  throw new ReorderError('Failed to add items to cart')
126
129
  }
@@ -132,7 +135,7 @@ export const useReorder = () => {
132
135
  id: getItemId(item),
133
136
  })),
134
137
  messages: validated.messages,
135
- shouldSplitItem: validated.order.shouldSplitItem,
138
+ shouldSplitItem: validated.order.shouldSplitItem ?? undefined,
136
139
  }
137
140
 
138
141
  cartStore.set(updatedCart)
@@ -18,8 +18,9 @@ import { createValidationStore, useStore } from '../useStore'
18
18
 
19
19
  export interface CartItem
20
20
  extends SDKCartItem,
21
- Omit<CartItemFragment, 'isGift'> {
21
+ Omit<CartItemFragment, 'isGift' | 'priceToken'> {
22
22
  isGift?: boolean | null
23
+ priceToken?: string | null
23
24
  }
24
25
 
25
26
  export interface Cart extends SDKCart<CartItem> {
@@ -58,6 +59,7 @@ export const ValidateCartMutation = gql(`
58
59
  listPrice
59
60
  listPriceWithTaxes
60
61
  isGift
62
+ priceToken
61
63
  itemOffered {
62
64
  ...CartProductItem
63
65
  }
@@ -128,12 +130,14 @@ const validateCart = async (cart: Cart): Promise<Cart | null> => {
128
130
  seller,
129
131
  quantity,
130
132
  itemOffered,
133
+ priceToken,
131
134
  }): IStoreOffer => {
132
135
  return {
133
136
  price,
134
137
  listPrice,
135
138
  seller,
136
139
  quantity,
140
+ priceToken,
137
141
  itemOffered: {
138
142
  sku: itemOffered.sku,
139
143
  image: itemOffered.image,
@@ -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
+ }