@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/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +82 -80
- package/@generated/gql.ts +21 -21
- package/@generated/graphql.ts +82 -30
- package/@generated/persisted-documents.json +11 -11
- package/@generated/schema.graphql +54 -5
- package/CHANGELOG.md +12 -0
- package/package.json +6 -6
- package/src/components/product/ProductCard/ProductCard.tsx +1 -0
- package/src/components/search/SearchProductItem/SearchProductItem.tsx +2 -0
- package/src/components/sections/EmptyState/EmptyState.tsx +4 -3
- package/src/components/sections/ProductDetails/ProductDetails.tsx +1 -0
- 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/components/ui/ProductComparison/ProductComparisonSidebar.tsx +4 -0
- package/src/components/ui/ProductDetails/ProductDetailsSettings.tsx +2 -0
- package/src/components/ui/SKUMatrix/SKUMatrixSidebar.tsx +11 -1
- package/src/experimental/index.ts +2 -0
- package/src/pages/[...slug].tsx +5 -0
- package/src/pages/[slug]/p.tsx +85 -12
- package/src/sdk/account/useReorder.ts +7 -4
- package/src/sdk/cart/index.ts +5 -1
- package/src/sdk/localization/LocalizedProductContext.tsx +65 -0
- package/src/sdk/localization/useBindingSelector.ts +148 -5
- package/src/sdk/offer/index.ts +7 -2
- package/src/server/index.ts +10 -1
- package/test/sdk/localization/useBindingSelector.test.tsx +144 -1
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
|
+
"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/
|
|
75
|
-
"@faststore/
|
|
76
|
-
"@faststore/sdk": "4.5.0-dev.
|
|
77
|
-
"@faststore/
|
|
78
|
-
"@faststore/ui": "4.5.0-dev.
|
|
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",
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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: [
|
|
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'
|
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,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: {
|
|
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
|
-
{
|
|
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
|
-
<
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
82
|
-
listPrice: item.price
|
|
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
|
|
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)
|
package/src/sdk/cart/index.ts
CHANGED
|
@@ -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
|
+
}
|