@faststore/core 4.5.0-dev.2 → 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.
Files changed (31) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +81 -75
  3. package/@generated/gql.ts +6 -6
  4. package/@generated/graphql.ts +46 -7
  5. package/@generated/persisted-documents.json +2 -2
  6. package/@generated/schema.graphql +44 -5
  7. package/CHANGELOG.md +12 -0
  8. package/cms/faststore/components/cms_component__recommendationshelf.jsonc +7 -1
  9. package/cms/faststore/schema.json +7 -1
  10. package/discovery.config.default.js +0 -3
  11. package/package.json +6 -6
  12. package/src/Layout.tsx +4 -5
  13. package/src/components/sections/EmptyState/EmptyState.tsx +4 -3
  14. package/src/components/sections/RecommendationShelf/README.md +163 -0
  15. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +3 -4
  16. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +8 -1
  17. package/src/components/templates/ProductListingPage/ProductListingPage.tsx +43 -37
  18. package/src/components/ui/Breadcrumb/Breadcrumb.tsx +5 -12
  19. package/src/components/ui/LocalizationButton/LocalizationButton.tsx +5 -1
  20. package/src/experimental/index.ts +2 -0
  21. package/src/pages/[...slug].tsx +5 -0
  22. package/src/pages/[slug]/p.tsx +63 -11
  23. package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +38 -18
  24. package/src/sdk/analytics/utils/hasEnabledRecommendationShelf.ts +49 -0
  25. package/src/sdk/localization/LocalizedProductContext.tsx +65 -0
  26. package/src/sdk/localization/useBindingSelector.ts +148 -5
  27. package/src/server/index.ts +10 -1
  28. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +32 -45
  29. package/test/sdk/analytics/hasEnabledRecommendationShelf.test.ts +88 -0
  30. package/test/sdk/analytics/useStartRecommendationSession.test.ts +90 -15
  31. 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.4](https://github.com/vtex/faststore/compare/v4.5.0-dev.3...v4.5.0-dev.4) (2026-07-24)
7
+
8
+ ### Features
9
+
10
+ - **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)
11
+
12
+ # [4.5.0-dev.3](https://github.com/vtex/faststore/compare/v4.5.0-dev.2...v4.5.0-dev.3) (2026-07-22)
13
+
14
+ ### Features
15
+
16
+ - **core:** move recommendation session start to CMS Global Section ([8589056](https://github.com/vtex/faststore/commit/8589056046b38e5dbc4f8ce6dcc669f7a15a4d85))
17
+
6
18
  # [4.5.0-dev.2](https://github.com/vtex/faststore/compare/v4.5.0-dev.1...v4.5.0-dev.2) (2026-07-20)
7
19
 
8
20
  **Note:** Version bump only for package @faststore/core
@@ -3,10 +3,16 @@
3
3
  "$componentKey": "RecommendationShelf",
4
4
  "$componentTitle": "Recommendation Shelf",
5
5
  "title": "Recommendation Shelf",
6
- "description": "Display a carousel of personalized product recommendations from a VTEX Recommendations campaign.",
6
+ "description": "Display a carousel of personalized product recommendations from a VTEX Recommendations campaign. Enable the shelf with the Enable recommendations toggle (off by default).",
7
7
  "type": "object",
8
8
  "required": ["campaignVrn"],
9
9
  "properties": {
10
+ "enableRecommendations": {
11
+ "title": "Enable recommendations?",
12
+ "description": "When enabled, starts the VTEX Recommendations personalization session (once per browser session) and fetches personalized products for this shelf. Off by default so stores that do not use Recommendations never hit the Recommendations API.",
13
+ "type": "boolean",
14
+ "default": false
15
+ },
10
16
  "title": {
11
17
  "title": "Title",
12
18
  "description": "Override the shelf title. If not set, the campaign title will be used.",
@@ -4434,10 +4434,16 @@
4434
4434
  "$componentKey": "RecommendationShelf",
4435
4435
  "$componentTitle": "Recommendation Shelf",
4436
4436
  "title": "Recommendation Shelf",
4437
- "description": "Display a carousel of personalized product recommendations from a VTEX Recommendations campaign.",
4437
+ "description": "Display a carousel of personalized product recommendations from a VTEX Recommendations campaign. Enable the shelf with the Enable recommendations toggle (off by default).",
4438
4438
  "type": "object",
4439
4439
  "required": ["campaignVrn"],
4440
4440
  "properties": {
4441
+ "enableRecommendations": {
4442
+ "title": "Enable recommendations?",
4443
+ "description": "When enabled, starts the VTEX Recommendations personalization session (once per browser session) and fetches personalized products for this shelf. Off by default so stores that do not use Recommendations never hit the Recommendations API.",
4444
+ "type": "boolean",
4445
+ "default": false
4446
+ },
4441
4447
  "title": {
4442
4448
  "title": "Title",
4443
4449
  "description": "Override the shelf title. If not set, the campaign title will be used.",
@@ -201,9 +201,6 @@ module.exports = {
201
201
  },
202
202
  refreshToken: false,
203
203
  scrollRestoration: false,
204
- // Opt-in flag for VTEX Recommendations. When disabled (default), the store
205
- // never starts a personalization session nor calls the Recommendations API.
206
- enableRecommendations: false,
207
204
  /** Package names to transpile (e.g. ['@vtex/components']). Use a non-empty list to enable Next.js transpilation. */
208
205
  transpilePackages: [],
209
206
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.5.0-dev.2",
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/api": "4.5.0-dev.2",
75
- "@faststore/lighthouse": "4.5.0-dev.2",
76
- "@faststore/diagnostics": "4.5.0-dev.2",
77
- "@faststore/sdk": "4.5.0-dev.2",
78
- "@faststore/ui": "4.5.0-dev.2"
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",
package/src/Layout.tsx CHANGED
@@ -6,11 +6,10 @@ import { useStartRecommendationSession } from './sdk/analytics/hooks/useStartRec
6
6
  function Layout({ children }: PropsWithChildren) {
7
7
  const props = isValidElement(children) ? children.props : undefined
8
8
  usePageViewEvent(props)
9
- // Implemented here because the personalization session must be initiated once
10
- // per browser session on each page. Gated behind the
11
- // `experimental.enableRecommendations` flag: stores that don't opt into
12
- // Recommendations never start a session nor call the Recommendations API.
13
- useStartRecommendationSession()
9
+
10
+ // Starts the personalization session once per browser session when the page
11
+ // CMS data includes a RecommendationShelf with `enableRecommendations: true`.
12
+ useStartRecommendationSession(props)
14
13
 
15
14
  return <>{children}</>
16
15
  }
@@ -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
 
@@ -0,0 +1,163 @@
1
+ # Recommendation Shelf
2
+
3
+ CMS section that renders a carousel of personalized product recommendations from a **VTEX Recommendations** campaign. It uses the same product cards (`ProductCard`) as other store shelves; items come from a recommendation campaign (cross-sell, similar items, top sellers, personalized, and related types) instead of a search query.
4
+
5
+ - **Data source:** VTEX Recommendations BFF, exposed by `@faststore/api` through the `recommendations` GraphQL query.
6
+ - **Registration:** global CMS component (`$componentKey: "RecommendationShelf"`), available on any page via CMS.
7
+ - **Opt-in via CMS:** enable Recommendations with the **Enable recommendations?** toggle on the shelf (default `false`). No store code changes or feature flags are required.
8
+
9
+ ## Contents
10
+
11
+ - [Prerequisites](#prerequisites)
12
+ - [Setup](#setup)
13
+ - [CMS configuration](#cms-configuration)
14
+ - [Campaign types (VRN) and context](#campaign-types-vrn-and-context)
15
+ - [Runtime behavior](#runtime-behavior)
16
+ - [Code-level overrides](#code-level-overrides)
17
+ - [Placement guidelines](#placement-guidelines)
18
+ - [Privacy and cache](#privacy-and-cache)
19
+ - [Troubleshooting](#troubleshooting)
20
+
21
+ ## Prerequisites
22
+
23
+ The shelf renders products only when the following conditions are met:
24
+
25
+ 1. **Enable recommendations on the shelf.** Set **Enable recommendations?** to `true` on the Recommendation Shelf in the CMS. This opts the store into the personalization session (`startRecommendationSession` mutation, owned by Layout) and allows the shelf to fetch recommendations. Without it (default `false`), no session is started and the shelf does not fetch.
26
+
27
+ 2. **Active VTEX Recommendations campaign.** Provide a valid campaign **VRN**. Example: `vrn:recommendations:my-account:rec-persona-v2:abc123`.
28
+
29
+ 3. **Activity Flow script (optional, recommended).** Used for analytics:
30
+ - reads PDP `product:*` meta tags to capture product view events;
31
+ - reads `data-af-*` attributes on the shelf to capture impression, view, and click events.
32
+
33
+ ## Setup
34
+
35
+ ### 1. Obtain the campaign VRN
36
+
37
+ In the VTEX Recommendations admin, create or select a campaign and copy its **VRN**. The expected format is:
38
+
39
+ ```text
40
+ vrn:recommendations:<account>:<campaign-type>:<campaign-id>
41
+ ```
42
+
43
+ Supported `<campaign-type>` values are listed under [Campaign types (VRN) and context](#campaign-types-vrn-and-context).
44
+
45
+ ### 2. Add Recommendation Shelf via CMS
46
+
47
+ Add **Recommendation Shelf** to the target page (home, PDP, PLP, cart, and so on), configure at least **Campaign VRN** (required), and turn **Enable recommendations?** on.
48
+
49
+ ## CMS configuration
50
+
51
+ Schema: `cms/faststore/components/cms_component__recommendationshelf.jsonc`.
52
+
53
+ | CMS property | Key | Type | Required | Default | Description |
54
+ | --- | --- | --- | --- | --- | --- |
55
+ | Enable recommendations? | `enableRecommendations` | boolean | No | `false` | Opt-in that starts the personalization session (via Layout) and allows this shelf to fetch recommendations. |
56
+ | Title | `title` | string | No | — | Overrides the shelf title. Falls back to the campaign title. If both are empty, the heading is not rendered. |
57
+ | Campaign VRN | `campaignVrn` | string | **Yes** | — | Recommendation campaign VRN. Validated by regex (see campaign types below). |
58
+ | Items context | `itemsContext` | `PDP` \| `CART` | No | `PDP` | Source of products used as request context. Applies only to context-based campaigns. |
59
+ | **Carousel Configuration** | `carouselConfiguration` | object | No | — | Carousel settings. |
60
+ | › Items per page (desktop) | `itemsPerPageDesktop` | number | No | `4` | Items per page on desktop. |
61
+ | › Items per page (mobile) | `itemsPerPageMobile` | number | No | `2` | Items per page on mobile and tablet (≤ 768px). |
62
+ | › Carousel track variant | `variant` | `slide` \| `scroll` | No | `scroll` | Carousel navigation mode. |
63
+ | › Infinite navigation? | `infiniteMode` | boolean | No | `false` | Infinite navigation (slide variant only). |
64
+ | › Navigation controls | `controls` | `complete` \| `navigationArrows` \| `paginationBullets` | No | `complete` | Visible navigation controls. |
65
+ | **Product Card Configuration** | `productCardConfiguration` | object | No | — | Product card settings. |
66
+ | › Show discount badge? | `showDiscountBadge` | boolean | No | `true` | Shows the discount badge. |
67
+ | › Cards should be bordered? | `bordered` | boolean | No | `true` | Renders bordered cards. |
68
+
69
+ ## Campaign types (VRN) and context
70
+
71
+ The campaign type is derived from the `<campaign-type>` segment of the VRN:
72
+
73
+ | Campaign | VRN segment | Internal type | Requires product context? |
74
+ | --- | --- | --- | --- |
75
+ | Cross-Sell | `rec-cross-v2` | `CROSS_SELL` | **Yes** |
76
+ | Similar Items | `rec-similar-v2` | `SIMILAR_ITEMS` | **Yes** |
77
+ | Visual Similarity | `rec-visual-v2` | `VISUAL_SIMILARITY` | **Yes** |
78
+ | Next Interactions | `rec-next-v2` | `NEXT_INTERACTION` | **Yes** |
79
+ | Personalized | `rec-persona-v2` | `PERSONALIZED` | No |
80
+ | Top Sellers | `rec-top-items-v2` | `TOP_ITEMS` | No |
81
+ | Last Seen | `rec-last-v2` | `LAST_SEEN` | No |
82
+ | Search-Based | `rec-search-v2` | `SEARCH_BASED` | No |
83
+
84
+ **Product context (`itemsContext`):**
85
+
86
+ - `PDP` (default): uses the current product detail page product (`productGroupID`). Outside a PDP there is no product context.
87
+ - `CART`: uses the products currently in the cart (deduplicated). Intended for cross-sell on the cart page.
88
+
89
+ Context-based campaigns (cross-sell, similar items, visual similarity, next interaction) skip the request when no context products are available. Context-agnostic campaigns (top sellers, personalized, last seen, search-based) ignore `itemsContext`.
90
+
91
+ ## Runtime behavior
92
+
93
+ All recommendation work runs client-side after hydration:
94
+
95
+ 1. **Session start (CMS opt-in)** — `Layout` calls `useStartRecommendationSession(pageProps)` once. The hook checks whether any `RecommendationShelf` in the CMS data has `enableRecommendations: true` and no-ops when none do. Multiple enabled shelves on the same page still start the session only once (in-memory lock + session cookie).
96
+ 2. **User id resolution** — `useRecommendationUserId()` reads the `vtex-rec-user-id` cookie, retrying until a value is available or the retry budget is exhausted.
97
+ 3. **Request arguments** — when enabled, `getRecommendationArguments()` builds `{ userId, campaignVrn, products }` or returns `null` when the VRN is invalid, the user id is missing, or a context-based campaign has no context products. When disabled, arguments stay `null`.
98
+ 4. **Fetch** — `useRecommendations()` runs `ClientRecommendationsQuery` and returns `products`, `correlationId`, and `campaign`.
99
+ 5. **Render** — shows `ProductShelfSkeleton` while loading; returns `null` on error or empty results; otherwise renders the carousel and optional heading.
100
+ 6. **Tracking** — when correlation and campaign identifiers are present, the shelf emits `data-af-*` attributes for Activity Flow. PDP product views use `product:*` meta tags from `pages/[slug]/p.tsx`.
101
+
102
+ Related files:
103
+
104
+ - Shelf: `src/components/sections/RecommendationShelf/`
105
+ - Hook: `src/sdk/analytics/hooks/useStartRecommendationSession.ts`
106
+ - Layout: `src/Layout.tsx`
107
+
108
+ ## Code-level overrides
109
+
110
+ In addition to CMS props, the component accepts code-level overrides (not exposed in the CMS schema):
111
+
112
+ ```tsx
113
+ import { RecommendationShelf } from 'src/components/sections/RecommendationShelf'
114
+
115
+ <RecommendationShelf
116
+ enableRecommendations
117
+ campaignVrn="vrn:recommendations:my-account:rec-cross-v2:abc123"
118
+ itemsContext="PDP"
119
+ ProductCard={MyCustomCard}
120
+ mapProductToProductCard={(product, index) => ({
121
+ product,
122
+ index,
123
+ highlight: index === 0,
124
+ })}
125
+ carouselConfiguration={{ itemsPerPageDesktop: 5, variant: 'slide' }}
126
+ />
127
+ ```
128
+
129
+ - **`ProductCard`**: custom card component (defaults to the core `ProductCard`).
130
+ - **`mapProductToProductCard`**: maps each recommended product (normalized `StoreProduct`) into card props. When provided, it fully owns the card props and the default `productCardConfiguration` merge no longer applies.
131
+
132
+ Campaign types and VRN validation are defined in `vrn.ts` (`VRN_TYPE_TO_RECOMMENDATION`). Updates should be made there and mirrored in the CMS schema `pattern`.
133
+
134
+ ## Placement guidelines
135
+
136
+ As a global component, the shelf can be placed on any CMS page. Suggested placements by campaign type:
137
+
138
+ - **Home / institutional pages:** context-agnostic campaigns (top sellers, personalized, last seen, search-based).
139
+ - **PDP:** context-based campaigns with `itemsContext: PDP`.
140
+ - **Cart:** cross-sell with `itemsContext: CART`.
141
+ - **PLP:** context-agnostic campaigns.
142
+
143
+ The personalization session runs only when at least one shelf has **Enable recommendations?** turned on.
144
+
145
+ ## Privacy and cache
146
+
147
+ - The `recommendations` query uses `@cacheControl(scope: "private", sMaxAge: 120, staleWhileRevalidate: 3600)`.
148
+ - Error logs omit `recommendationArgs` (which includes `userId`) and log only non-identifying context such as `campaignVrn`.
149
+ - BFF `vtex-rec-*` cookies are forwarded to the browser through `ctx.storage.cookies`.
150
+
151
+ ## Troubleshooting
152
+
153
+ **The shelf does not render.** Check, in order:
154
+
155
+ 1. Is **Enable recommendations?** set to `true` on the shelf?
156
+ 2. Is `campaignVrn` valid and is the campaign active?
157
+ 3. Is the `vtex-rec-user-id` cookie available? Without a user id, the shelf does not fetch.
158
+ 4. For context-based campaigns, is product context available (`PDP` on a product page, or a non-empty cart for `CART`)?
159
+ 5. Did the campaign return products? An empty response renders nothing by design.
160
+
161
+ **Skeleton never resolves.** Inspect the console and the GraphQL `recommendations` response for pending or failing requests.
162
+
163
+ **No tracking events.** Confirm that the Activity Flow script is present and that PDP `product:*` meta tags and shelf `data-af-*` attributes are rendered.
@@ -4,8 +4,6 @@ import { usePDP } from '@faststore/core'
4
4
  import { ProductShelf, Carousel } from '@faststore/ui'
5
5
  import type { ProductSummary_ProductFragment } from '@generated/graphql'
6
6
 
7
- import storeConfig from 'discovery.config'
8
-
9
7
  import DefaultProductCard, {
10
8
  type ProductCardProps,
11
9
  } from 'src/components/product/ProductCard'
@@ -66,6 +64,7 @@ export function RecommendationShelf<
66
64
  >({
67
65
  title,
68
66
  campaignVrn,
67
+ enableRecommendations = false,
69
68
  itemsContext = 'PDP',
70
69
  ProductCard,
71
70
  mapProductToProductCard,
@@ -120,9 +119,9 @@ export function RecommendationShelf<
120
119
  return pdpProduct ? [pdpProduct] : []
121
120
  }, [itemsContext, cartItems, productDetailPage])
122
121
 
123
- // Gate the whole feature behind the opt-in flag: when Recommendations is
122
+ // Gate the whole feature behind the CMS opt-in: when Recommendations is
124
123
  // disabled the shelf never requests recommendations (nor renders).
125
- const recommendationArgs = storeConfig.experimental.enableRecommendations
124
+ const recommendationArgs = enableRecommendations
126
125
  ? getRecommendationArguments(campaignVrn, {
127
126
  userId,
128
127
  contextProducts,
@@ -30,6 +30,13 @@ export type RecommendationShelfProps<
30
30
  > = {
31
31
  readonly title?: string
32
32
  readonly campaignVrn: string
33
+ /**
34
+ * Opt-in for VTEX Recommendations on this shelf. When `true`, Layout starts
35
+ * the personalization session (`useStartRecommendationSession`) and this
36
+ * shelf fetches recommendations. When `false` (default), neither happens.
37
+ * @default false
38
+ */
39
+ readonly enableRecommendations?: boolean
33
40
  /**
34
41
  * Where to read the products used as context for the recommendation request:
35
42
  * - `'PDP'`: the current product detail page product.
@@ -45,7 +52,7 @@ export type RecommendationShelfProps<
45
52
  /**
46
53
  * Custom card component rendered for each recommended product. Defaults to the
47
54
  * core `ProductCard`. This is a code-level override and is not exposed through
48
- * the CMS schema (`cms_component__RecommendationShelf.jsonc`).
55
+ * the CMS schema (`cms_component__recommendationshelf.jsonc`).
49
56
  */
50
57
  readonly ProductCard?: ComponentType<TCardProps>
51
58
  /**
@@ -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
  }