@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/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +78 -70
- package/@generated/gql.ts +6 -6
- package/@generated/graphql.ts +46 -7
- package/@generated/persisted-documents.json +2 -2
- package/@generated/schema.graphql +44 -5
- package/CHANGELOG.md +6 -0
- package/package.json +6 -6
- package/src/components/sections/EmptyState/EmptyState.tsx +4 -3
- package/src/components/templates/ProductListingPage/ProductListingPage.tsx +43 -37
- package/src/components/ui/Breadcrumb/Breadcrumb.tsx +5 -12
- package/src/components/ui/LocalizationButton/LocalizationButton.tsx +5 -1
- package/src/experimental/index.ts +2 -0
- package/src/pages/[...slug].tsx +5 -0
- package/src/pages/[slug]/p.tsx +63 -11
- package/src/sdk/localization/LocalizedProductContext.tsx +65 -0
- package/src/sdk/localization/useBindingSelector.ts +148 -5
- package/src/server/index.ts +10 -1
- package/test/sdk/localization/useBindingSelector.test.tsx +144 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faststore/core",
|
|
3
|
-
"version": "4.5.0-dev.
|
|
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/
|
|
75
|
-
"@faststore/
|
|
76
|
-
"@faststore/sdk": "4.5.0-dev.
|
|
77
|
-
"@faststore/
|
|
78
|
-
"@faststore/
|
|
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
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
shouldResetInfiniteScroll={!storeConfig.experimental?.scrollRestoration}
|
|
149
|
-
{...searchParams}
|
|
146
|
+
<LocalizedProductProvider
|
|
147
|
+
otherLocales={server.collection?.otherLocales}
|
|
148
|
+
urlSuffix=""
|
|
150
149
|
>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
title
|
|
170
|
-
description
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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'
|
package/src/pages/[...slug].tsx
CHANGED
|
@@ -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
|
{
|
package/src/pages/[slug]/p.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
<
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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(
|
|
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
|
-
//
|
|
185
|
-
|
|
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
|
-
`${
|
|
323
|
+
`${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`
|
|
188
324
|
)
|
|
189
|
-
}, [
|
|
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
|
|
package/src/server/index.ts
CHANGED
|
@@ -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),
|