@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.
- package/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +81 -75
- 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 +12 -0
- package/cms/faststore/components/cms_component__recommendationshelf.jsonc +7 -1
- package/cms/faststore/schema.json +7 -1
- package/discovery.config.default.js +0 -3
- package/package.json +6 -6
- package/src/Layout.tsx +4 -5
- package/src/components/sections/EmptyState/EmptyState.tsx +4 -3
- package/src/components/sections/RecommendationShelf/README.md +163 -0
- package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +3 -4
- package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +8 -1
- 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/analytics/hooks/useStartRecommendationSession.ts +38 -18
- package/src/sdk/analytics/utils/hasEnabledRecommendationShelf.ts +49 -0
- 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/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +32 -45
- package/test/sdk/analytics/hasEnabledRecommendationShelf.test.ts +88 -0
- package/test/sdk/analytics/useStartRecommendationSession.test.ts +90 -15
- 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.
|
|
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/
|
|
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",
|
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
|
-
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
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
|
|
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 =
|
|
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 (`
|
|
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
|
-
<
|
|
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
|
}
|