diff --git a/package-lock.json b/package-lock.json
index 2a8e622ada..8650c6ee75 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2252,7 +2252,6 @@
"integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@octokit/auth-token": "^3.0.0",
"@octokit/graphql": "^5.0.0",
@@ -3362,7 +3361,6 @@
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.62.0",
@@ -3398,7 +3396,6 @@
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@@ -3643,7 +3640,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5404,7 +5400,6 @@
"node": ">=0.10.0"
}
},
-
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -5688,7 +5683,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -10495,7 +10489,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@nrwl/cli": "15.9.7",
"@nrwl/tao": "15.9.7",
@@ -11603,7 +11596,6 @@
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"playwright-core": "cli.js"
},
@@ -11651,7 +11643,6 @@
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin-prettier.js"
},
diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md
index 14fca63a17..77bf9edc37 100644
--- a/packages/commerce-sdk-react/CHANGELOG.md
+++ b/packages/commerce-sdk-react/CHANGELOG.md
@@ -1,4 +1,5 @@
## v5.1.0-dev
+- Add Page Designer Support
- Bump commerce-sdk-isomorphic to 5.1.0
- Add Node 24 support. Drop Node 16 support. [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
- Add Shopper Consents API support [#3674](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3674)
diff --git a/packages/commerce-sdk-react/PAGE_DESIGNER.md b/packages/commerce-sdk-react/PAGE_DESIGNER.md
new file mode 100644
index 0000000000..b77eade756
--- /dev/null
+++ b/packages/commerce-sdk-react/PAGE_DESIGNER.md
@@ -0,0 +1,555 @@
+# Page Designer Migration Guide
+
+This guide helps you migrate from the old Page Designer components to the new implementation with visual editing support.
+
+## What Changed
+
+The `Page`, `Region`, and `Component` components have been redesigned to support:
+
+- **Visual Editing** - Edit pages directly in Business Manager's Page Designer
+- **Component Registry** - Lazy loading via a centralized registry (no more `components` prop)
+- **Design Mode** - Components receive design metadata for visual editing
+- **Nested Regions** - New API for layout components with child regions
+
+## Migration Steps
+
+### Step 1: Update Page Usage
+
+The `Page` component no longer requires a `components` prop. Components are now resolved via the registry.
+
+**Before:**
+```jsx
+import {Page} from '@salesforce/commerce-sdk-react/components'
+
+// Had to pass components map
+const components = {
+ 'commerce_assets.imageTile': ImageTile,
+ 'commerce_assets.banner': Banner
+}
+
+
+```
+
+**After:**
+```jsx
+import {Page} from '@salesforce/commerce-sdk-react/components'
+
+// Components are resolved from the registry automatically
+
+```
+
+### Step 2: Set Up the Component Registry
+
+Register your components once during app initialization:
+
+```jsx
+// app/page-designer/registry.js
+import {registry} from '@salesforce/commerce-sdk-react'
+
+export function initializeRegistry() {
+ registry.registerImporter(
+ 'commerce_assets.imageTile',
+ () => import('./assets/image-tile')
+ )
+ registry.registerImporter(
+ 'commerce_assets.banner',
+ () => import('./assets/banner')
+ )
+}
+```
+
+Initialize in your app (e.g., `_app/index.jsx`):
+
+```jsx
+import {useEffect} from 'react'
+import {initializeRegistry} from '@salesforce/retail-react-app/app/page-designer/registry'
+
+function App() {
+ useEffect(() => {
+ initializeRegistry()
+ }, [])
+ // ...
+}
+```
+
+### Step 3: Update Region Usage in Layout Components
+
+The `Region` component API changed significantly. It now uses `regionId` instead of receiving the region object directly.
+
+**Before:**
+```jsx
+import {Region} from '@salesforce/commerce-sdk-react/components'
+
+function TwoColumn({regions}) {
+ return (
+
+ )
+}
+```
+
+**After:**
+```jsx
+import {Region} from '@salesforce/commerce-sdk-react/components'
+
+function TwoColumn({component}) {
+ return (
+
+ )
+}
+```
+
+**Key Changes:**
+- Pass `component` (the parent component) instead of `regions`
+- Use `regionId` to specify which region to render
+- The `Region` finds the region data from `component.regions`
+
+### Step 4: Update Component Props
+
+Components now receive additional props for design mode support.
+
+**Before:**
+```jsx
+function ImageTile({image, alt, link}) {
+ return (
+
+
+
+ )
+}
+```
+
+**After:**
+```jsx
+function ImageTile({image, alt, link, designMetadata, component, regionId}) {
+ // designMetadata contains: id, name, isVisible, isLocalized, isFragment
+ // component contains the full component data
+ // regionId is the parent region's ID
+
+ return (
+
+
+
+ )
+}
+```
+
+You don't need to use the new props, but they're available if needed.
+
+### Step 5: Enable Visual Editing (Optional)
+
+To enable visual editing in Business Manager, wrap your app with `PageDesignerProvider`:
+
+```jsx
+import {
+ PageDesignerProvider,
+ isDesignModeActive,
+ isPreviewModeActive
+} from '@salesforce/commerce-sdk-react/components'
+
+function App({children}) {
+ // Check if we're in Page Designer context
+ const isDesignMode = isDesignModeActive()
+ const isPreviewMode = isPreviewModeActive()
+
+ if (isDesignMode || isPreviewMode) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return children
+}
+```
+
+## API Changes Summary
+
+### Page Component
+
+| Before | After |
+|--------|-------|
+| `` | `` |
+| Required `components` prop | Components from registry |
+| Used `PageContext` internally | No context needed |
+
+### Region Component
+
+| Before | After |
+|--------|-------|
+| `` | `` |
+| Received region object directly | Finds region by ID from component |
+| No fallback support | `fallbackElement` and `errorElement` props |
+
+**New Region Props:**
+```typescript
+// For page-level regions
+} />
+
+// For nested regions in layout components
+} />
+```
+
+### Component (Internal)
+
+The `Component` is now internal and uses the registry. You don't interact with it directly.
+
+| Before | After |
+|--------|-------|
+| Used `usePageContext()` for component map | Uses `registry.getComponent()` |
+| Wrapped in `` | No wrapper div |
+| Synchronous rendering | Suspense-based lazy loading |
+
+## New Features
+
+### Design Metadata
+
+Components receive `designMetadata` with information for visual editing:
+
+```typescript
+interface ComponentDesignMetadata {
+ id: string // Component instance ID
+ name?: string // Display name
+ isFragment: boolean // Is this a fragment?
+ isVisible: boolean // Is component visible?
+ isLocalized: boolean // Is component localized?
+}
+```
+
+### Design Mode Detection
+
+Use the hook to conditionally render content:
+
+```jsx
+import {usePageDesignerMode} from '@salesforce/commerce-sdk-react/components'
+
+function MyComponent() {
+ const {isDesignMode, isPreviewMode} = usePageDesignerMode()
+
+ return (
+
+ {isDesignMode && Editing mode}
+ {/* ... */}
+
+ )
+}
+```
+
+Or use the utility functions:
+
+```jsx
+import {isDesignModeActive, isPreviewModeActive} from '@salesforce/commerce-sdk-react/components'
+
+if (isDesignModeActive()) {
+ // In design mode
+}
+```
+
+### Component Registry
+
+```javascript
+import {registry} from '@salesforce/commerce-sdk-react'
+
+// Register with lazy loading
+registry.registerImporter('typeId', () => import('./component'))
+
+// Register with fallback for loading state
+registry.registerImporter('typeId', () => import('./component'), () => import('./skeleton'))
+
+// Get a component
+const Component = registry.getComponent('typeId')
+
+// Preload a component
+await registry.preload('typeId')
+```
+
+## PageDesignerProvider
+
+The provider enables communication with Business Manager's Page Designer for visual editing.
+
+```jsx
+
// 'EDIT' or 'PREVIEW'
+ {children}
+
+```
+
+**When to use:**
+- Wrap your app when loaded inside Page Designer's iframe
+- Use `isDesignModeActive()` or `isPreviewModeActive()` to detect context
+- Only needed for visual editing support
+
+## Template Retail React App Changes
+
+If you're using `template-retail-react-app`, here are the specific changes needed:
+
+### 1. Create the Registry File
+
+Create `app/page-designer/registry.js`:
+
+```javascript
+import {registry} from '@salesforce/commerce-sdk-react'
+
+export function initializeRegistry() {
+ // Commerce Assets
+ registry.registerImporter('commerce_assets.imageTile', () => import('./assets/image-tile'))
+ registry.registerImporter('commerce_assets.imageAndText', () => import('./assets/image-with-text'))
+ registry.registerImporter('commerce_assets.productTile', () => import('./assets/product-tile'))
+
+ // Commerce Layouts
+ registry.registerImporter('commerce_layouts.carousel', () => import('./layouts/carousel'))
+ registry.registerImporter('commerce_layouts.mobileGrid1r1c', () => import('./layouts/mobileGrid1r1c'))
+ registry.registerImporter('commerce_layouts.mobileGrid2r1c', () => import('./layouts/mobileGrid2r1c'))
+ registry.registerImporter('commerce_layouts.mobileGrid2r2c', () => import('./layouts/mobileGrid2r2c'))
+ registry.registerImporter('commerce_layouts.mobileGrid2r3c', () => import('./layouts/mobileGrid2r3c'))
+ registry.registerImporter('commerce_layouts.mobileGrid3r1c', () => import('./layouts/mobileGrid3r1c'))
+ registry.registerImporter('commerce_layouts.mobileGrid3r2c', () => import('./layouts/mobileGrid3r2c'))
+}
+```
+
+### 2. Initialize Registry at Module Load
+
+In `app/components/_app/index.jsx`, add the registry initialization at the top level (outside the component):
+
+```javascript
+import {initializeRegistry} from '@salesforce/retail-react-app/app/page-designer/registry'
+
+// Initialize registry synchronously at module load time so components are available during SSR
+initializeRegistry()
+```
+
+### 3. Add PageDesignerProvider to App
+
+In `app/components/_app/index.jsx`, wrap your app content with `PageDesignerProvider`:
+
+```javascript
+import {PageDesignerProvider} from '@salesforce/commerce-sdk-react/components'
+import {useUsid} from '@salesforce/commerce-sdk-react'
+
+const App = (props) => {
+ const {usid} = useUsid()
+
+ // Detect Page Designer mode from URL
+ const pageDesignerMode = useMemo(() => {
+ const queryParams = location?.search || ''
+ if (queryParams.includes('mode=EDIT')) return 'EDIT'
+ if (queryParams.includes('mode=PREVIEW')) return 'PREVIEW'
+ return undefined
+ }, [])
+
+ return (
+ // ... existing providers ...
+
+ {children}
+
+ )
+}
+```
+
+### 4. Create PageDesignerInit Component
+
+Create `app/components/page-designer-init/index.jsx` to handle design mode behaviors:
+
+```javascript
+import React, {useEffect} from 'react'
+import {Prompt} from 'react-router-dom'
+import {usePageDesignerMode} from '@salesforce/commerce-sdk-react/components'
+import {useGlobalAnchorBlock} from '@salesforce/retail-react-app/app/hooks/use-global-anchor-block'
+
+export function PageDesignerInit() {
+ const {isDesignMode} = usePageDesignerMode()
+
+ // Block anchor navigation when in design mode
+ useGlobalAnchorBlock(isDesignMode)
+
+ // Load Page Designer styles only in design mode
+ useEffect(() => {
+ if (isDesignMode) {
+ void import('@salesforce/storefront-next-runtime/design/styles.css')
+ }
+ }, [isDesignMode])
+
+ // Block React Router navigation in design mode
+ return (
+
false} />
+ )
+}
+
+export default PageDesignerInit
+```
+
+### 5. Create useGlobalAnchorBlock Hook
+
+Create `app/hooks/use-global-anchor-block.js` to prevent link navigation in design mode:
+
+```javascript
+import {useEffect} from 'react'
+
+export function useGlobalAnchorBlock(enabled = true) {
+ useEffect(() => {
+ if (typeof window === 'undefined' || !enabled) return
+
+ function preventAnchorClicks(event) {
+ const anchor = event.target.closest('a')
+ // Allow links with data-pd-allow-link attribute
+ if (anchor && !anchor.hasAttribute('data-pd-allow-link')) {
+ event.preventDefault()
+ }
+ }
+
+ document.addEventListener('click', preventAnchorClicks)
+ return () => document.removeEventListener('click', preventAnchorClicks)
+ }, [enabled])
+}
+```
+
+### 6. Update Layout Components
+
+Update all layout components to use the new Region API. Example for `mobileGrid2r2c`:
+
+**Before:**
+```jsx
+export const MobileGrid2r2c = ({regions}) => (
+
+ {regions.map((region) => (
+
+ ))}
+
+)
+```
+
+**After:**
+```jsx
+export const MobileGrid2r2c = ({regions}) => (
+
+ {regions.map((region) => {
+ const component = {regions}
+ return (
+
+ )
+ })}
+
+)
+```
+
+### 7. Add PageDesignerInit to App
+
+In `app/components/_app/index.jsx`, render `PageDesignerInit` inside the provider:
+
+```jsx
+
+
+ {children}
+
+```
+
+## Complete Migration Example
+
+**Before (Old API):**
+```jsx
+// page-viewer.jsx
+import {Page} from '@salesforce/commerce-sdk-react/components'
+import ImageTile from './components/image-tile'
+import Banner from './components/banner'
+import TwoColumn from './components/two-column'
+
+const components = {
+ 'commerce_assets.imageTile': ImageTile,
+ 'commerce_assets.banner': Banner,
+ 'commerce_layouts.twoColumn': TwoColumn
+}
+
+function PageViewer({pageData}) {
+ return
+}
+
+// two-column.jsx
+import {Region} from '@salesforce/commerce-sdk-react/components'
+
+function TwoColumn({regions}) {
+ return (
+
+
+
+
+ )
+}
+```
+
+**After (New API):**
+```jsx
+// registry.js
+import {registry} from '@salesforce/commerce-sdk-react'
+
+export function initializeRegistry() {
+ registry.registerImporter('commerce_assets.imageTile', () => import('./assets/image-tile'))
+ registry.registerImporter('commerce_assets.banner', () => import('./assets/banner'))
+ registry.registerImporter('commerce_layouts.twoColumn', () => import('./layouts/two-column'))
+}
+
+// _app/index.jsx
+import {initializeRegistry} from './page-designer/registry'
+initializeRegistry() // At module level, not in useEffect
+
+// page-viewer.jsx
+import {Page} from '@salesforce/commerce-sdk-react/components'
+
+function PageViewer({pageData}) {
+ return
+}
+
+// two-column.jsx
+import {Region} from '@salesforce/commerce-sdk-react/components'
+
+function TwoColumn({component}) {
+ return (
+
+
+
+
+ )
+}
+```
+
+## Troubleshooting
+
+### Components Not Rendering
+
+1. Verify `initializeRegistry()` is called on app startup
+2. Check that component type IDs match exactly (case-sensitive)
+3. Ensure components have a default export
+
+### Region Not Found
+
+1. Verify `regionId` matches the region ID in your page data
+2. For nested regions, pass `component` not `page`
+3. Use `errorElement` prop to handle missing regions gracefully
+
+### Visual Editing Not Working
+
+1. Ensure `PageDesignerProvider` wraps your content
+2. Verify `targetOrigin` matches your Business Manager URL
+3. Check browser console for postMessage errors
diff --git a/packages/commerce-sdk-react/PAGE_DESIGNER_ARCHITECTURE.md b/packages/commerce-sdk-react/PAGE_DESIGNER_ARCHITECTURE.md
new file mode 100644
index 0000000000..b293247f41
--- /dev/null
+++ b/packages/commerce-sdk-react/PAGE_DESIGNER_ARCHITECTURE.md
@@ -0,0 +1,224 @@
+# Page Designer Concepts
+
+This document explains the key concepts behind the Page Designer integration for PWA Kit.
+
+## How It Works
+
+```
+Business Manager Page Designer
+ ↓ (creates JSON)
+ Page Data (API)
+ ↓ (fetched by)
+ PWA Kit App
+ ↓ (renders via)
+ Page → Region → Component
+ ↓ (resolves from)
+ Component Registry
+```
+
+### Runtime vs Design Mode
+
+The system operates in two modes:
+
+1. **Runtime Mode** (normal browsing)
+ - Components render directly from page data
+ - No visual editing overhead
+ - Optimized for performance
+
+2. **Design Mode** (in Page Designer iframe)
+ - Components are decorated for visual editing
+ - Click-to-select, drag-and-drop enabled
+ - Communicates with Business Manager via postMessage
+
+## Component Hierarchy
+
+### Page
+
+The top-level container that:
+- Receives page data from the ShopperExperience API
+- Sets up SEO metadata (title, description, keywords)
+- Renders top-level regions
+
+### Region
+
+A container for components that:
+- Can exist at page level or nested inside layout components
+- Finds its components by `regionId`
+- Supports fallback and error elements
+
+**Two modes of use:**
+```jsx
+// Page-level region
+
+
+// Nested region (inside a layout component)
+
+```
+
+### Component
+
+Resolves and renders individual Page Designer components:
+- Looks up the React component from the registry by `typeId`
+- Passes component data as props
+- Handles lazy loading via Suspense
+
+## Component Registry
+
+The registry is a central place to map Page Designer type IDs to React components.
+
+### Why a Registry?
+
+- **Lazy Loading** - Components load only when needed
+- **Code Splitting** - Each component is a separate chunk
+- **Decoupling** - Page data doesn't need to know about React components
+
+### How It Works
+
+```
+Page Data: { typeId: "commerce_assets.banner", data: {...} }
+ ↓
+Registry: "commerce_assets.banner" → () => import('./banner')
+ ↓
+React Component:
+```
+
+## Visual Editing
+
+When your app runs inside Page Designer's iframe, it enables visual editing.
+
+### PageDesignerProvider
+
+Wraps your app to enable design mode features:
+
+```jsx
+
+ {children}
+
+```
+
+### What It Enables
+
+- **Component Selection** - Click components to select them
+- **Visual Indicators** - Borders and overlays show component boundaries
+- **Live Updates** - Changes in Page Designer reflect immediately
+- **Communication** - postMessage bridge to Business Manager
+
+### Mode Detection
+
+Detect if you're in design mode:
+
+```jsx
+import {usePageDesignerMode} from '@salesforce/commerce-sdk-react/components'
+
+function MyComponent() {
+ const {isDesignMode, isPreviewMode} = usePageDesignerMode()
+ // ...
+}
+```
+
+Or use utility functions:
+
+```jsx
+import {isDesignModeActive, isPreviewModeActive} from '@salesforce/commerce-sdk-react/components'
+```
+
+## Data Flow
+
+### Page Data Structure
+
+```typescript
+Page
+├── id: string
+├── pageTitle, pageDescription, pageKeywords
+└── regions: Region[]
+ └── Region
+ ├── id: string
+ └── components: Component[]
+ └── Component
+ ├── id: string
+ ├── typeId: string (maps to registry)
+ ├── data: {...} (your component props)
+ └── regions?: Region[] (for layouts)
+```
+
+### Props Your Components Receive
+
+```jsx
+function MyComponent({
+ // Your custom attributes from Page Designer
+ title,
+ image,
+ link,
+
+ // Automatic props
+ designMetadata, // { id, name, isVisible, isLocalized, isFragment }
+ component, // Full component object
+ regionId, // Parent region's ID
+ regions // Child regions (for layout components)
+}) {
+ // ...
+}
+```
+
+## Layout Components
+
+Layout components contain regions that hold other components.
+
+### Example Structure
+
+```
+TwoColumnLayout (component)
+├── regions.left (region)
+│ └── Banner (component)
+└── regions.right (region)
+ └── ImageTile (component)
+```
+
+### Implementing a Layout
+
+```jsx
+function TwoColumnLayout({component}) {
+ return (
+
+
+
+
+ )
+}
+```
+
+## Performance
+
+### Zero Overhead in Runtime
+
+When not in design mode:
+- No design decorators applied
+- No postMessage listeners
+- Components render directly
+
+### Lazy Loading
+
+Components load on demand:
+- Initial bundle stays small
+- Each component is a separate chunk
+- Suspense handles loading states
+
+### Fallbacks
+
+Provide loading states for better UX:
+
+```jsx
+registry.registerImporter(
+ 'commerce_assets.banner',
+ () => import('./banner'),
+ () => import('./banner-skeleton') // Optional fallback
+)
+```
+
+## Related
+
+- [Migration Guide](./PAGE_DESIGNER.md) - Step-by-step migration instructions
+- [Page Designer API](https://developer.salesforce.com/docs/commerce/commerce-api/guide/page-designer.html)
diff --git a/packages/commerce-sdk-react/jest.config.js b/packages/commerce-sdk-react/jest.config.js
index f836ca23d2..fe5627a9e6 100644
--- a/packages/commerce-sdk-react/jest.config.js
+++ b/packages/commerce-sdk-react/jest.config.js
@@ -11,6 +11,19 @@ module.exports = {
...base,
setupFilesAfterEnv: ['./setup-jest.js'],
transformIgnorePatterns: [],
+ moduleNameMapper: {
+ ...base.moduleNameMapper,
+ '^@salesforce/storefront-next-runtime/design/react/core$':
+ '@salesforce/storefront-next-runtime/dist/design-react-core.js',
+ '^@salesforce/storefront-next-runtime/design/react$':
+ '@salesforce/storefront-next-runtime/dist/design-react.js',
+ '^@salesforce/storefront-next-runtime/design$':
+ '@salesforce/storefront-next-runtime/dist/design.js',
+ '^@salesforce/storefront-next-runtime/design/mode$':
+ '@salesforce/storefront-next-runtime/dist/design-mode.js',
+ '^@salesforce/storefront-next-runtime/scapi$':
+ '@salesforce/storefront-next-runtime/dist/scapi.js'
+ },
coverageThreshold: {
global: {
branches: 0,
diff --git a/packages/commerce-sdk-react/package-lock.json b/packages/commerce-sdk-react/package-lock.json
index 6224885922..6080ac01b5 100644
--- a/packages/commerce-sdk-react/package-lock.json
+++ b/packages/commerce-sdk-react/package-lock.json
@@ -9,6 +9,7 @@
"version": "5.1.0-dev",
"license": "See license in LICENSE",
"dependencies": {
+ "@salesforce/storefront-next-runtime": "0.1.1",
"commerce-sdk-isomorphic": "5.1.0",
"js-cookie": "^3.0.1",
"jwt-decode": "^4.0.0"
@@ -230,6 +231,22 @@
"node": ">= 8"
}
},
+ "node_modules/@salesforce/storefront-next-runtime": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@salesforce/storefront-next-runtime/-/storefront-next-runtime-0.1.1.tgz",
+ "integrity": "sha512-j8TrIMppOdO2T+IcGfaENei3hcE3yrq3fuWj2o8yfIDB555FU9Td9DFA/UuxOSnsMqQrFw2+X3GG1rsTBoZHGA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "openapi-fetch": "0.15.0"
+ },
+ "engines": {
+ "node": ">=24.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=19.0.0",
+ "react-dom": ">=19.0.0"
+ }
+ },
"node_modules/@sinclair/typebox": {
"version": "0.34.45",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.45.tgz",
@@ -2675,6 +2692,21 @@
"wrappy": "1"
}
},
+ "node_modules/openapi-fetch": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.15.0.tgz",
+ "integrity": "sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "openapi-typescript-helpers": "^0.0.15"
+ }
+ },
+ "node_modules/openapi-typescript-helpers": {
+ "version": "0.0.15",
+ "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz",
+ "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==",
+ "license": "MIT"
+ },
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
diff --git a/packages/commerce-sdk-react/package.json b/packages/commerce-sdk-react/package.json
index 4e1b57aa72..a7a464b0d4 100644
--- a/packages/commerce-sdk-react/package.json
+++ b/packages/commerce-sdk-react/package.json
@@ -17,7 +17,7 @@
"files": [
"CHANGELOG.md",
"LICENSE",
- "+(auth|components|hooks|scripts)/**/!(*.test*).{ts,js}",
+ "+(auth|components|hooks|scripts|page-designer)/**/!(*.test*).{ts,js}",
"*.{js,d.ts}",
"!*.test*.{js,d.ts}",
"!test*.*",
@@ -41,6 +41,7 @@
},
"dependencies": {
"commerce-sdk-isomorphic": "5.1.0",
+ "@salesforce/storefront-next-runtime": "0.1.1",
"js-cookie": "^3.0.1",
"jwt-decode": "^4.0.0"
},
@@ -87,6 +88,12 @@
"node": "^18.0.0 || ^20.0.0 || ^22.0.0 || ^24.0.0",
"npm": "^9.0.0 || ^10.0.0 || ^11.0.0"
},
+ "overrides": {
+ "@salesforce/storefront-next-runtime": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ }
+ },
"publishConfig": {
"directory": "dist"
}
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Component/index.test.tsx b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Component/index.test.tsx
new file mode 100644
index 0000000000..794d341264
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Component/index.test.tsx
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2023, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React from 'react'
+import {render, screen} from '@testing-library/react'
+import {Component, ComponentProps} from './index'
+import {registry} from '../registry'
+
+// Mock the registry
+jest.mock('../registry', () => ({
+ registry: {
+ getComponent: jest.fn(),
+ getFallback: jest.fn(),
+ preload: jest.fn()
+ }
+}))
+
+// Type the mock registry with flexible return types for testing
+const mockRegistry = registry as unknown as {
+ getComponent: jest.Mock
+ getFallback: jest.Mock
+ preload: jest.Mock
+}
+
+describe('Component', () => {
+ // Create mock component data - cast to ComponentProps['component'] for test flexibility
+ const mockComponent = {
+ id: 'test-component-id',
+ typeId: 'commerce_assets.banner',
+ data: {
+ title: 'Test Banner',
+ imageUrl: '/test-image.jpg'
+ },
+ visible: true,
+ localized: false,
+ designMetadata: {
+ name: 'Test Component'
+ },
+ regions: []
+ } as unknown as ComponentProps['component']
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ // Suppress console.log during tests
+ jest.spyOn(console, 'log').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ test('renders component when DynamicComponent is available', () => {
+ const MockDynamicComponent = ({title}: {title: string}) => (
+ {title}
+ )
+ mockRegistry.getComponent.mockReturnValue(MockDynamicComponent)
+ mockRegistry.getFallback.mockReturnValue(null)
+
+ render()
+
+ expect(screen.getByTestId('dynamic-component')).toBeInTheDocument()
+ expect(screen.getByText('Test Banner')).toBeInTheDocument()
+ })
+
+ test('calls preload when DynamicComponent is not available', () => {
+ const preloadPromise = Promise.resolve()
+ mockRegistry.getComponent.mockReturnValue(undefined)
+ mockRegistry.getFallback.mockReturnValue(null)
+ mockRegistry.preload.mockReturnValue(preloadPromise)
+
+ // Component throws the preload promise for Suspense to catch
+ // We can't test the throw directly because React catches it internally
+ // Instead we verify preload is called with the correct typeId
+ try {
+ render()
+ } catch (e) {
+ // Expected - Suspense boundary catches this
+ }
+
+ expect(mockRegistry.preload).toHaveBeenCalledWith('commerce_assets.banner')
+ })
+
+ test('passes correct props to DynamicComponent', () => {
+ const receivedProps: Record = {}
+ const MockDynamicComponent = (props: Record) => {
+ Object.assign(receivedProps, props)
+ return Test
+ }
+ mockRegistry.getComponent.mockReturnValue(MockDynamicComponent)
+ mockRegistry.getFallback.mockReturnValue(null)
+
+ render(
+
+ )
+
+ expect(receivedProps.title).toBe('Test Banner')
+ expect(receivedProps.imageUrl).toBe('/test-image.jpg')
+ expect(receivedProps.className).toBe('custom-class')
+ expect(receivedProps.regionId).toBe('test-region')
+ expect(receivedProps.component).toBe(mockComponent)
+ expect(receivedProps.regions).toEqual([])
+ expect(receivedProps.designMetadata).toEqual({
+ name: 'Test Component',
+ isFragment: false,
+ isVisible: true,
+ isLocalized: false,
+ id: 'test-component-id'
+ })
+ })
+
+ test('handles component without designMetadata', () => {
+ const componentWithoutDesignMetadata = {
+ ...mockComponent,
+ designMetadata: undefined
+ } as unknown as ComponentProps['component']
+ const receivedProps: Record = {}
+ const MockDynamicComponent = (props: Record) => {
+ Object.assign(receivedProps, props)
+ return Test
+ }
+ mockRegistry.getComponent.mockReturnValue(MockDynamicComponent)
+ mockRegistry.getFallback.mockReturnValue(null)
+
+ render()
+
+ expect((receivedProps.designMetadata as {name: string | undefined}).name).toBeUndefined()
+ })
+})
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Component/index.tsx b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Component/index.tsx
new file mode 100644
index 0000000000..e953eb6941
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Component/index.tsx
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2026, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React, {
+ type ReactElement,
+ memo,
+ Suspense,
+ type ComponentType as ReactComponentType
+} from 'react'
+import {registry} from '../registry'
+import type {ComponentDesignMetadata} from '@salesforce/storefront-next-runtime/design/react'
+import type {ComponentType} from '../types'
+
+export interface ComponentProps {
+ component: ComponentType
+ className?: string
+ regionId: string
+}
+
+/**
+ * Props that are passed to dynamic components loaded from the registry.
+ * This includes design metadata, component data, and region information.
+ */
+interface DynamicComponentProps extends Record {
+ designMetadata: ComponentDesignMetadata
+ component: ComponentType
+ regions?: ComponentType['regions']
+ className?: string
+ regionId: string
+}
+
+export const Component = memo(function Component({
+ component,
+ className,
+ regionId
+}: ComponentProps): ReactElement {
+ // Get this component's data promise from context by its ID
+ const FallbackComponent = registry.getFallback(component.typeId)
+ const DynamicComponent = registry.getComponent(component.typeId)
+
+ if (!DynamicComponent) {
+ throw registry.preload(component.typeId)
+ }
+
+ // visible and localized are runtime properties not in the API schema
+ const componentWithRuntimeProps = component as ComponentType & {
+ visible?: boolean
+ localized?: boolean
+ }
+ const designMetadata: ComponentDesignMetadata = {
+ name: component.designMetadata?.name,
+ isFragment: false,
+ isVisible: Boolean(componentWithRuntimeProps.visible),
+ isLocalized: Boolean(componentWithRuntimeProps.localized),
+ id: component.id
+ }
+
+ // Cast DynamicComponent to accept our props since registry returns unknown type
+ const ComponentToRender = DynamicComponent as ReactComponentType
+
+ const componentElement = (
+
+ )
+
+ // Only use Suspense on the client side since during SSR it will respond with the whole page loaded
+ const isClient = typeof window !== 'undefined'
+
+ if (!isClient) {
+ return {componentElement}
+ }
+
+ return (
+ :
+ }
+ >
+ {componentElement}
+
+ )
+})
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Page/index.test.tsx b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Page/index.test.tsx
new file mode 100644
index 0000000000..0558ce7143
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Page/index.test.tsx
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2023, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React from 'react'
+import {render} from '@testing-library/react'
+import Page from './index'
+import {Helmet} from 'react-helmet'
+import type {PageWithDesignMetadata} from '../types'
+
+// Mock the Component to avoid registry dependency
+jest.mock('../Component', () => ({
+ Component: ({component}: {component: {id: string; typeId: string}}) => (
+
+ {component.typeId}
+
+ )
+}))
+
+// Mock the RegionWrapper
+jest.mock('../Region/region-wrapper', () => ({
+ RegionWrapper: ({children, className}: {children: React.ReactNode; className?: string}) => (
+ {children}
+ )
+}))
+
+const SAMPLE_PAGE = {
+ id: 'samplepage',
+ typeId: 'storePage',
+ aspectTypeId: 'pdpAspect',
+ name: 'Sample Page',
+ description: 'Sample page of the storefront.',
+ pageTitle: 'title',
+ pageDescription: 'description',
+ pageKeywords: 'keywords',
+ regions: [
+ {
+ id: 'regionA',
+ components: [
+ {
+ id: 'iofwj38fhw3f',
+ typeId: 'commerce_assets.banner',
+ data: {
+ title: 'Products On Sale',
+ bannerImage: 'sale/topsellerPromo.jpg'
+ }
+ }
+ ]
+ },
+ {
+ id: 'regionB',
+ components: [
+ {
+ id: 'rfdvj4ojtltljw3',
+ typeId: 'commerce_assets.carousel',
+ data: {
+ title: 'Topseller',
+ category: 'topseller'
+ },
+ regions: [
+ {
+ id: 'regionB1',
+ components: [
+ {
+ id: 'rfdvj4ojtltljw3',
+ typeId: 'commerce_assets.carousel',
+ data: {
+ title: 'Topseller',
+ category: 'topseller'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ id: 'regionC',
+ components: []
+ }
+ ]
+} as unknown as PageWithDesignMetadata
+
+beforeEach(() => {
+ // Suppress console.log during tests
+ jest.spyOn(console, 'log').mockImplementation(() => {})
+})
+
+afterEach(() => {
+ jest.restoreAllMocks()
+})
+
+test('Page renders without errors', () => {
+ const {container} = render()
+
+ // Page is in document.
+ expect(container.querySelector('[id=samplepage]')).toBeInTheDocument()
+
+ // Meta data and title are set
+ const helmet = Helmet.peek()
+ expect(helmet.title).toBe('title')
+ expect(
+ helmet.metaTags.find(
+ ({name, content}) => name === 'description' && content === 'description'
+ )
+ ).toBeTruthy()
+ expect(
+ helmet.metaTags.find(({name, content}) => name === 'keywords' && content === 'keywords')
+ ).toBeTruthy()
+
+ // Regions are in document.
+ expect(container.querySelectorAll('.region')?.length).toBe(3)
+
+ // Components are in document. (Note: Sub-regions/components aren't rendered because that is
+ // the responsibility of the component definition.)
+ expect(container.querySelectorAll('.component')?.length).toBe(2)
+})
+
+test('Page renders with empty page data', () => {
+ const emptyPage = {
+ id: 'emptypage',
+ regions: []
+ } as unknown as PageWithDesignMetadata
+ const {container} = render()
+
+ expect(container.querySelector('[id=emptypage]')).toBeInTheDocument()
+ expect(container.querySelectorAll('.region')?.length).toBe(0)
+})
+
+test('Page renders without meta tags when not provided', () => {
+ const pageWithoutMeta = {
+ id: 'nometa',
+ regions: []
+ } as unknown as PageWithDesignMetadata
+ render()
+
+ const helmet = Helmet.peek()
+ expect(helmet.title).toBeUndefined()
+})
+
+test('Page applies custom className', () => {
+ const simplePage = {
+ id: 'simplepage',
+ regions: []
+ } as unknown as PageWithDesignMetadata
+ const {container} = render()
+
+ expect(container.querySelector('.page.custom-page-class')).toBeInTheDocument()
+})
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Page/index.tsx b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Page/index.tsx
new file mode 100644
index 0000000000..3a071093a3
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Page/index.tsx
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2023, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React from 'react'
+import {Helmet} from 'react-helmet'
+import type {Component as ComponentType, PageWithDesignMetadata} from '../types'
+import {Region} from '../Region'
+
+type ComponentMap = {
+ [typeId: string]: React.ComponentType
+}
+
+interface PageProps extends React.ComponentProps<'div'> {
+ page: PageWithDesignMetadata
+ components?: ComponentMap
+}
+
+type PageContextValue = {
+ components: ComponentMap
+}
+
+// This context will hold the component map as well as any other future context.
+export const PageContext = React.createContext(undefined)
+
+/**
+ * This component will render a page designer page given its serialized data object.
+ * It wraps the page with PageDesignerProvider to enable design mode capabilities.
+ *
+ * @param {PageProps} props
+ * @param {Page} props.page - The page designer page data representation.
+ * @param {ComponentMap} props.components - A mapping of typeId's to react components representing the type.
+ * @param {Object} props.pageDesigner - Optional Page Designer configuration for design mode.
+ * @returns {React.ReactElement} - Page component.
+ */
+export const Page = (props: PageProps) => {
+ const {page, className = '', ...rest} = props
+ const {id, regions, pageDescription, pageKeywords, pageTitle} = page || {}
+
+ return (
+
+
+ {pageTitle && {pageTitle}}
+ {pageDescription && }
+ {pageKeywords && }
+
+
+
+ {regions?.map((region) => (
+
+ ))}
+
+
+
+ )
+}
+
+Page.displayName = 'Page'
+
+export default Page
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/index.test.tsx b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/index.test.tsx
new file mode 100644
index 0000000000..5dee5d92b7
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/index.test.tsx
@@ -0,0 +1,221 @@
+/*
+ * Copyright (c) 2023, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React from 'react'
+import {render, screen} from '@testing-library/react'
+import {Region, ComponentType} from './index'
+import type {PageWithDesignMetadata} from '../types'
+
+// Mock the Component
+jest.mock('../Component', () => ({
+ Component: ({component}: {component: {id: string; typeId: string}}) => (
+
+ {component.typeId}
+
+ )
+}))
+
+// Mock the RegionWrapper
+jest.mock('./region-wrapper', () => ({
+ RegionWrapper: ({children, className}: {children: React.ReactNode; className?: string}) => (
+
+ {children}
+
+ )
+}))
+
+describe('Region', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('Page Mode', () => {
+ const mockPage = {
+ id: 'test-page',
+ regions: [
+ {
+ id: 'main-region',
+ components: [
+ {id: 'comp-1', typeId: 'commerce_assets.banner', data: {}},
+ {id: 'comp-2', typeId: 'commerce_assets.carousel', data: {}}
+ ]
+ },
+ {
+ id: 'sidebar-region',
+ components: [{id: 'comp-3', typeId: 'commerce_assets.promo', data: {}}]
+ },
+ {
+ id: 'empty-region',
+ components: []
+ }
+ ],
+ designMetadata: {
+ id: 'test-page-metadata',
+ name: 'Test Page',
+ regionDefinitions: [
+ {
+ id: 'main-region',
+ componentTypeExclusions: ['excluded-type'],
+ componentTypeInclusions: ['included-type']
+ }
+ ]
+ }
+ } as unknown as PageWithDesignMetadata
+
+ test('renders page region with components', () => {
+ render()
+
+ expect(screen.getByTestId('region-wrapper')).toBeInTheDocument()
+ expect(screen.getByTestId('component-comp-1')).toBeInTheDocument()
+ expect(screen.getByTestId('component-comp-2')).toBeInTheDocument()
+ })
+
+ test('renders empty region without components', () => {
+ render()
+
+ expect(screen.getByTestId('region-wrapper')).toBeInTheDocument()
+ expect(screen.queryByTestId(/^component-/)).not.toBeInTheDocument()
+ })
+
+ test('returns null when region is not found and no errorElement', () => {
+ const {container} = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test('renders errorElement when region is not found', () => {
+ render(
+ Region not found }
+ />
+ )
+
+ expect(screen.getByTestId('error')).toBeInTheDocument()
+ expect(screen.getByText('Region not found')).toBeInTheDocument()
+ })
+
+ test('applies className to region', () => {
+ render()
+
+ const wrapper = screen.getByTestId('region-wrapper')
+ expect(wrapper).toHaveClass('custom-class')
+ })
+
+ test('handles page with no regions', () => {
+ const pageWithNoRegions = {
+ id: 'test-page',
+ regions: undefined
+ } as unknown as PageWithDesignMetadata
+ const {container} = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('Component Mode', () => {
+ const mockComponent = {
+ id: 'parent-component',
+ typeId: 'commerce_layouts.grid',
+ data: {},
+ regions: [
+ {
+ id: 'nested-region',
+ components: [
+ {id: 'nested-comp-1', typeId: 'commerce_assets.text', data: {}},
+ {id: 'nested-comp-2', typeId: 'commerce_assets.image', data: {}}
+ ]
+ }
+ ],
+ designMetadata: {
+ name: 'Parent Component',
+ regionDefinitions: [
+ {
+ id: 'nested-region',
+ componentTypeExclusions: [],
+ componentTypeInclusions: []
+ }
+ ]
+ }
+ } as unknown as ComponentType
+
+ test('renders component region with nested components', () => {
+ render()
+
+ expect(screen.getByTestId('region-wrapper')).toBeInTheDocument()
+ expect(screen.getByTestId('component-nested-comp-1')).toBeInTheDocument()
+ expect(screen.getByTestId('component-nested-comp-2')).toBeInTheDocument()
+ })
+
+ test('returns null when component region is not found and no errorElement', () => {
+ const {container} = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test('renders errorElement when component region is not found', () => {
+ render(
+ Nested region not found}
+ />
+ )
+
+ expect(screen.getByTestId('error')).toBeInTheDocument()
+ })
+
+ test('handles component with no regions', () => {
+ const componentWithNoRegions = {
+ id: 'comp',
+ typeId: 'test',
+ data: {},
+ regions: undefined
+ } as unknown as ComponentType
+ const {container} = render(
+
+ )
+
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('Design Metadata', () => {
+ test('passes design metadata to RegionWrapper for page regions', () => {
+ const mockPage = {
+ id: 'test-page',
+ regions: [{id: 'main', components: []}],
+ designMetadata: {
+ id: 'test-page-metadata',
+ name: 'Test Page',
+ regionDefinitions: [
+ {
+ id: 'main',
+ componentTypeExclusions: ['excluded'],
+ componentTypeInclusions: ['included']
+ }
+ ]
+ }
+ } as unknown as PageWithDesignMetadata
+
+ render()
+
+ expect(screen.getByTestId('region-wrapper')).toBeInTheDocument()
+ })
+
+ test('handles missing design metadata gracefully', () => {
+ const mockPage = {
+ id: 'test-page',
+ regions: [{id: 'main', components: []}]
+ } as unknown as PageWithDesignMetadata
+
+ render()
+
+ expect(screen.getByTestId('region-wrapper')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/index.tsx b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/index.tsx
new file mode 100644
index 0000000000..2d849aede5
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/index.tsx
@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) 2026, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React, {type ReactElement, type ReactNode, Suspense} from 'react'
+import {Component} from '../Component'
+import {RegionWrapper} from './region-wrapper'
+import type {ShopperExperience} from '@salesforce/storefront-next-runtime/scapi'
+import {
+ PageDesignerPageMetadataProvider,
+ useRegionContext
+} from '@salesforce/storefront-next-runtime/design/react/core'
+import type {
+ ComponentDecoratorProps,
+ ComponentDesignMetadata,
+ RegionDesignMetadata
+} from '@salesforce/storefront-next-runtime/design/react'
+
+export type {RegionDesignMetadata}
+
+export interface PageDesignMetadata {
+ id: string
+ name: string
+ description?: string
+ archType?: 'controller' | 'headless'
+ route?: string
+ supportedAspectTypes?: string[]
+ regionDefinitions?: RegionDesignMetadata[]
+ attributeDefinitionGroups?: {
+ id: string
+ name?: string
+ description?: string
+ attributeDefinitions?: Record[]
+ }[]
+}
+
+export type PageDecoratorProps = React.PropsWithChildren<
+ {
+ designMetadata?: PageDesignMetadata
+ } & TProps
+>
+
+// Extended Page type with design metadata
+type PageWithDesignMetadata = PageDecoratorProps & {
+ componentData?: Record>
+}
+
+// Props when rendering a page-level region
+interface PageRegionProps extends React.HTMLAttributes {
+ page: PageWithDesignMetadata
+ component?: never
+ regionId: string
+ fallbackElement?: ReactNode
+ errorElement?: ReactNode
+}
+
+export type ComponentType = ComponentDecoratorProps
+
+// Props when rendering a component-level region (nested)
+interface ComponentRegionProps extends React.HTMLAttributes {
+ page?: never
+ component: ComponentType
+ regionId: string
+ fallbackElement?: ReactNode
+ errorElement?: ReactNode
+}
+
+// Discriminated union
+export type RegionProps = PageRegionProps | ComponentRegionProps
+
+// Helper: Extract design metadata from region definition
+function getDesignMetadata(regionId: string, metadata?: RegionDesignMetadata) {
+ return {
+ id: regionId,
+ componentTypeExclusions: metadata?.componentTypeExclusions ?? [],
+ componentTypeInclusions: metadata?.componentTypeInclusions ?? []
+ }
+}
+
+// Helper: Render region wrapper with components
+function renderRegionContent(
+ region: ShopperExperience.schemas['Region'],
+ regionId: string,
+ metadata: RegionDesignMetadata | undefined,
+ className: string,
+ rest: React.HTMLAttributes
+) {
+ return (
+
+ {region.components?.map(
+ (comp) =>
+ comp.id && (
+
+ )
+ )}
+
+ )
+}
+
+/**
+ * Region - Renders a Page Designer region from Salesforce's ShopperExperience API data
+ *
+ * This component supports two distinct modes via a discriminated union:
+ *
+ * 1. **Page Mode** - For route-level regions:
+ * ```tsx
+ * } />
+ * ```
+ * - Accepts page (Promise or PageWithComponentData)
+ * - Wraps in Suspense for async loading
+ * - Provides ComponentDataContext at page level
+ * - Registers PageDesignerPageMetadataProvider for root regions
+ *
+ * 2. **Component Mode** - For nested regions in layout components:
+ * ```tsx
+ *
+ * ```
+ * - Accepts component (ShopperExperience.schemas['Component'])
+ * - Synchronous rendering (no Suspense overhead)
+ * - Inherits ComponentDataContext from parent
+ * - No PageDesignerPageMetadataProvider (only for page-level)
+ *
+ * Key Functionality:
+ * - TypeScript enforces you pass EITHER page OR component, never both
+ * - Finds the region by ID within the page or component
+ * - Renders all components within the region using the Component wrapper
+ * - Supports region-specific fallback and error elements
+ * - Handles metadata for component type inclusions/exclusions
+ *
+ * Use Case: Foundational component in Salesforce's Page Designer system for rendering
+ * regions that can contain multiple components managed through the Page Designer interface.
+ */
+export function Region(props: RegionProps): ReactElement | null {
+ const {regionId, className = '', errorElement, fallbackElement = , ...rest} = props
+ const regionContext = useRegionContext()
+
+ // COMPONENT MODE: Rendering a component-level region (nested)
+ if (props.component !== undefined) {
+ const region = props.component.regions?.find((r) => r.id === regionId)
+ if (!region) {
+ return errorElement ? <>{errorElement}> : null
+ }
+
+ const metadata = (
+ props.component.designMetadata as ComponentDesignMetadata & {
+ regionDefinitions?: RegionDesignMetadata[]
+ }
+ )?.regionDefinitions?.find((r: RegionDesignMetadata) => r.id === regionId)
+ return renderRegionContent(region, regionId, metadata, className, rest)
+ }
+
+ // PAGE MODE: Rendering a page-level region
+ const page = props.page
+ const region = page?.regions?.find((r) => r.id === regionId)
+ if (!region) {
+ return errorElement ? <>{errorElement}> : null
+ }
+
+ const metadata = page.designMetadata?.regionDefinitions?.find((r) => r.id === regionId)
+ const {...pageData} = page
+
+ return (
+
+ {!regionContext && }
+ {renderRegionContent(region, regionId, metadata, className, rest)}
+
+ )
+}
+
+export default Region
+// Re-export RegionWrapper for direct usage if needed
+export {RegionWrapper} from './region-wrapper'
+export type {RegionRendererProps} from './region-wrapper'
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/region-wrapper.test.tsx b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/region-wrapper.test.tsx
new file mode 100644
index 0000000000..c5e8d4f362
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/region-wrapper.test.tsx
@@ -0,0 +1,229 @@
+/*
+ * Copyright (c) 2023, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React from 'react'
+import {render, screen} from '@testing-library/react'
+import {RegionWrapper, RegionRendererProps} from './region-wrapper'
+
+const mockUsePageDesignerMode = jest.fn(() => ({isDesignMode: false}))
+
+jest.mock('@salesforce/storefront-next-runtime/design/react/core', () => ({
+ usePageDesignerMode: () => mockUsePageDesignerMode()
+}))
+
+jest.mock('@salesforce/storefront-next-runtime/design/react', () => {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const mockReact = require('react')
+ return {
+ createReactRegionDesignDecorator: (Component: typeof mockReact.ComponentType) => {
+ return function DecoratedComponent(props: Record) {
+ return mockReact.createElement(
+ 'div',
+ {'data-testid': 'decorated-region'},
+ mockReact.createElement(Component, props)
+ )
+ }
+ }
+ }
+})
+
+describe('RegionWrapper', () => {
+ const mockRegion = {
+ id: 'test-region',
+ components: [
+ {id: 'comp-1', typeId: 'banner'},
+ {id: 'comp-2', typeId: 'carousel'}
+ ]
+ }
+
+ const mockDesignMetadata: RegionRendererProps['designMetadata'] = {
+ id: 'test-region',
+ componentTypeExclusions: ['excluded-type'],
+ componentTypeInclusions: ['included-type']
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ // Suppress console.log during tests
+ jest.spyOn(console, 'log').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ describe('Runtime Mode (Design Mode Off)', () => {
+ beforeEach(() => {
+ mockUsePageDesignerMode.mockReturnValue({isDesignMode: false})
+ })
+
+ test('renders children directly without decoration', () => {
+ render(
+
+ Child Content
+
+ )
+
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ expect(screen.queryByTestId('decorated-region')).not.toBeInTheDocument()
+ })
+
+ test('renders with className', () => {
+ render(
+
+ Content
+
+ )
+
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ })
+
+ test('renders with design metadata', () => {
+ render(
+
+ Content
+
+ )
+
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ })
+
+ test('renders multiple children', () => {
+ render(
+
+ Child 1
+ Child 2
+ Child 3
+
+ )
+
+ expect(screen.getByTestId('child-1')).toBeInTheDocument()
+ expect(screen.getByTestId('child-2')).toBeInTheDocument()
+ expect(screen.getByTestId('child-3')).toBeInTheDocument()
+ })
+ })
+
+ describe('Design Mode (Design Mode On)', () => {
+ beforeEach(() => {
+ mockUsePageDesignerMode.mockReturnValue({isDesignMode: true})
+ })
+
+ test('renders with decoration when region has id', () => {
+ render(
+
+ Child Content
+
+ )
+
+ expect(screen.getByTestId('decorated-region')).toBeInTheDocument()
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ })
+
+ test('renders without decoration when region has no id', () => {
+ const regionWithoutId = {
+ ...mockRegion,
+ id: undefined
+ }
+
+ render(
+
+ Child Content
+
+ )
+
+ // Should fall back to non-decorated rendering
+ expect(screen.queryByTestId('decorated-region')).not.toBeInTheDocument()
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ })
+
+ test('passes design metadata to decorated component', () => {
+ render(
+
+ Content
+
+ )
+
+ expect(screen.getByTestId('decorated-region')).toBeInTheDocument()
+ })
+ })
+
+ describe('Design Metadata Computation', () => {
+ beforeEach(() => {
+ mockUsePageDesignerMode.mockReturnValue({isDesignMode: true})
+ })
+
+ test('computes componentIds from region components', () => {
+ render(
+
+ Content
+
+ )
+
+ // The component should compute componentIds: ['comp-1', 'comp-2']
+ expect(screen.getByTestId('decorated-region')).toBeInTheDocument()
+ })
+
+ test('handles region with no components', () => {
+ const regionWithNoComponents = {
+ id: 'empty-region',
+ components: undefined
+ }
+
+ render(
+
+ Content
+
+ )
+
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ })
+
+ test('handles region with empty components array', () => {
+ const regionWithEmptyComponents = {
+ id: 'empty-region',
+ components: []
+ }
+
+ render(
+
+ Content
+
+ )
+
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ })
+
+ test('uses default empty arrays when designMetadata is not provided', () => {
+ render(
+
+ Content
+
+ )
+
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Forwarding', () => {
+ beforeEach(() => {
+ mockUsePageDesignerMode.mockReturnValue({isDesignMode: false})
+ })
+
+ test('forwards additional HTML attributes', () => {
+ render(
+
+ Content
+
+ )
+
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/region-wrapper.tsx b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/region-wrapper.tsx
new file mode 100644
index 0000000000..67cfa4e77c
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/Region/region-wrapper.tsx
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2025, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+import React, {type ReactNode} from 'react'
+import {usePageDesignerMode} from '@salesforce/storefront-next-runtime/design/react/core'
+import {
+ createReactRegionDesignDecorator,
+ type RegionDesignMetadata
+} from '@salesforce/storefront-next-runtime/design/react'
+
+/**
+ * Props for the base region renderer
+ */
+export interface RegionRendererProps extends React.HTMLAttributes {
+ region: any
+ children: ReactNode
+ designMetadata?: Omit
+}
+
+/**
+ * Base region renderer component that handles the actual DOM structure
+ * This is the component that gets decorated in design mode
+ */
+function RegionRenderer({children}: RegionRendererProps) {
+ return <>{children}>
+}
+
+/**
+ * Create the design-mode decorated version of the region renderer
+ * This wraps the region with Page Designer functionality when in design mode
+ */
+const DecoratedRegionRenderer = createReactRegionDesignDecorator(RegionRenderer)
+
+/**
+ * RegionWrapper - Smart wrapper that conditionally applies design mode decoration
+ *
+ * This component provides a clean abstraction for rendering regions that:
+ * - Automatically detects design mode and applies the appropriate decorator
+ * - Maintains a simple API for region rendering
+ * - Handles design metadata when in Page Designer
+ *
+ * @example
+ * ```tsx
+ *
+ * {region.components.map(component => (
+ *
+ * ))}
+ *
+ * ```
+ */
+export function RegionWrapper({
+ region,
+ children,
+ className,
+ designMetadata,
+ ...rest
+}: RegionRendererProps) {
+ const {isDesignMode} = usePageDesignerMode()
+
+ // Memoize the complete design metadata to avoid creating new objects on every render
+ const fullDesignMetadata = React.useMemo(
+ () => ({
+ id: region.id,
+ componentIds: region?.components?.map((cmp: any) => cmp.id) || [],
+ componentTypeExclusions: designMetadata?.componentTypeExclusions || [],
+ componentTypeInclusions: designMetadata?.componentTypeInclusions || []
+ }),
+ [
+ region.id,
+ region?.components,
+ designMetadata?.componentTypeExclusions,
+ designMetadata?.componentTypeInclusions
+ ]
+ )
+
+ if (isDesignMode && region?.id) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ // At runtime, render directly without decoration overhead
+ return (
+
+ {children}
+
+ )
+}
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/index.ts b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/index.ts
new file mode 100644
index 0000000000..41be54aa9a
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/index.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2023, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+export * from './Component'
+export * from './Region'
+export * from './Page'
+export * from './prop-types'
+export * from './registry'
+
+// Re-export Page Designer utilities from runtime package for convenience
+export {
+ PageDesignerProvider,
+ usePageDesignerMode
+} from '@salesforce/storefront-next-runtime/design/react/core'
+
+// Re-export mode detection utilities
+export {
+ isDesignModeActive,
+ isPreviewModeActive
+} from '@salesforce/storefront-next-runtime/design/mode'
+export type {
+ ComponentDesignMetadata,
+ RegionDesignMetadata
+} from '@salesforce/storefront-next-runtime/design/react'
+
+// Export types
+export type {ComponentType, PageWithDesignMetadata} from './types'
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/prop-types.ts b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/prop-types.ts
new file mode 100644
index 0000000000..86fea2ab07
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/prop-types.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2023, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import PropTypes from 'prop-types'
+
+/**
+ * This PropType represents a `component` object from the ShopperExperience API.
+ */
+export const componentPropType = PropTypes.shape({
+ data: PropTypes.object,
+ id: PropTypes.string,
+ typeId: PropTypes.string
+})
+
+/**
+ * This PropType represents a `region` object from the ShopperExperience API.
+ */
+export const regionPropType = PropTypes.shape({
+ id: PropTypes.string,
+ components: PropTypes.arrayOf(componentPropType)
+})
+
+/**
+ * This PropType represents a `page` object from the ShopperExperience API.
+ */
+export const pageType = PropTypes.shape({
+ data: PropTypes.object,
+ description: PropTypes.string,
+ id: PropTypes.string,
+ name: PropTypes.string,
+ regions: PropTypes.arrayOf(regionPropType),
+ typeId: PropTypes.string
+})
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/registry.test.ts b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/registry.test.ts
new file mode 100644
index 0000000000..b33158436b
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/registry.test.ts
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2023, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import {createReactComponentRegistry, registry} from './registry'
+
+describe('registry', () => {
+ describe('createReactComponentRegistry', () => {
+ test('creates a new ComponentRegistry', () => {
+ const newRegistry = createReactComponentRegistry()
+
+ expect(newRegistry).toBeDefined()
+ // Cast to access internal methods for testing
+ const registryInstance = newRegistry as unknown as Record
+ expect(typeof registryInstance.registerComponent).toBe('function')
+ expect(typeof registryInstance.registerImporter).toBe('function')
+ expect(typeof registryInstance.getComponent).toBe('function')
+ expect(typeof registryInstance.getFallback).toBe('function')
+ expect(typeof registryInstance.preload).toBe('function')
+ })
+
+ test('creates typed registry', () => {
+ interface CustomProps {
+ title: string
+ count: number
+ }
+
+ const typedRegistry = createReactComponentRegistry()
+
+ expect(typedRegistry).toBeDefined()
+ })
+ })
+
+ describe('registry singleton', () => {
+ test('registry is defined', () => {
+ expect(registry).toBeDefined()
+ })
+
+ test('registry has expected methods', () => {
+ // Cast to access internal methods for testing
+ const registryInstance = registry as unknown as Record
+ expect(typeof registryInstance.register).toBe('function')
+ expect(typeof registryInstance.getComponent).toBe('function')
+ expect(typeof registryInstance.getFallback).toBe('function')
+ expect(typeof registryInstance.preload).toBe('function')
+ })
+ })
+})
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/registry.ts b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/registry.ts
new file mode 100644
index 0000000000..8cff696ed2
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/registry.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2025, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import {
+ createReactAdapter,
+ type ReactDesignComponentType
+} from '@salesforce/storefront-next-runtime/design/react'
+import {ComponentRegistry} from '@salesforce/storefront-next-runtime/design'
+
+/**
+ * Factory function to create a React-specific component registry
+ * with the React adapter pre-configured.
+ */
+export function createReactComponentRegistry() {
+ return new ComponentRegistry>({
+ adapter: createReactAdapter()
+ })
+}
+
+/**
+ * Global component registry instance.
+ * Used throughout the application to discover and load components.
+ *
+ * This singleton instance is configured with:
+ * - React adapter for React-specific behavior
+ * - Design mode decorator for Page Designer integration
+ * - Static component registration via Vite plugin (no dynamic discovery needed)
+ * - Component metadata handled via API (not stored in registry)
+ */
+// We don't care about the type of props of the components.
+// Just ignore them or else any combination of props won't be allowed.
+
+export const registry = createReactComponentRegistry()
diff --git a/packages/commerce-sdk-react/src/components/ShopperExperienceV2/types.ts b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/types.ts
new file mode 100644
index 0000000000..b1434c7fa8
--- /dev/null
+++ b/packages/commerce-sdk-react/src/components/ShopperExperienceV2/types.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2023, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+import {CLIENT_KEYS} from '../../constant'
+import {ApiClients, DataType} from '../../hooks/types' // TODO: Should we be moving these types to a more global place.
+import type {ShopperExperience} from '@salesforce/storefront-next-runtime/scapi'
+import type {ComponentDecoratorProps} from '@salesforce/storefront-next-runtime/design/react'
+import {PageDesignMetadata} from './Region'
+
+type ArrayElement = ArrayType[number]
+
+const CLIENT_KEY = CLIENT_KEYS.SHOPPER_EXPERIENCE
+type Client = NonNullable
+
+export type Page = DataType
+
+export type Region = ArrayElement>
+
+export type Component = ArrayElement>
+
+/**
+ * Extended Page type with design metadata and component data
+ *
+ * Uses the base Page type and adds optional component data map for async component data loading.
+ * This type is more flexible and compatible with both SDK-generated Page types and
+ * ShopperExperience.schemas['Page'].
+ */
+export type PageDecoratorProps = React.PropsWithChildren<
+ {
+ designMetadata?: PageDesignMetadata
+ } & TProps
+>
+
+// Extended Page type with design metadata
+export type PageWithDesignMetadata = PageDecoratorProps & {
+ componentData?: Record>
+}
+
+/**
+ * Component type with design decorator props
+ *
+ * Includes decorator props for Page Designer design mode on components.
+ */
+export type ComponentType = ComponentDecoratorProps
diff --git a/packages/commerce-sdk-react/src/hooks/ShopperExperience/query.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperExperience/query.test.ts
index 3f92243ffb..773a73bb19 100644
--- a/packages/commerce-sdk-react/src/hooks/ShopperExperience/query.test.ts
+++ b/packages/commerce-sdk-react/src/hooks/ShopperExperience/query.test.ts
@@ -69,3 +69,88 @@ describe('Shopper Experience query hooks', () => {
await waitAndExpectError(() => result.current)
})
})
+
+describe('Shopper Experience query hooks with Page Designer params', () => {
+ beforeEach(() => nock.cleanAll())
+ afterEach(() => {
+ expect(nock.pendingMocks()).toHaveLength(0)
+ })
+
+ test('usePage merges pageDesignerParams from provider config', async () => {
+ const pageData = {id: 'testPage', typeId: 'storePage'}
+ mockQueryEndpoint(experienceEndpoint, pageData)
+
+ const pageDesignerParams = {
+ mode: 'edit' as const,
+ pdToken: 'test-pd-token',
+ pageId: 'pd-page-id'
+ }
+
+ const {result} = renderHookWithProviders(
+ () => queries.usePage({parameters: {pageId: 'testPage'}}),
+ {pageDesignerParams}
+ )
+
+ await waitAndExpectSuccess(() => result.current)
+ expect(result.current.data).toEqual(pageData)
+ })
+
+ test('usePages merges pageDesignerParams from provider config', async () => {
+ const pagesData = {data: [{id: 'page1', typeId: 'storePage'}]}
+ mockQueryEndpoint(experienceEndpoint, pagesData)
+
+ const pageDesignerParams = {
+ mode: 'edit' as const,
+ pdToken: 'test-pd-token'
+ }
+
+ const {result} = renderHookWithProviders(
+ () => queries.usePages({parameters: {aspectTypeId: 'pdpAspect', categoryId: 'cat1'}}),
+ {pageDesignerParams}
+ )
+
+ await waitAndExpectSuccess(() => result.current)
+ expect(result.current.data).toEqual(pagesData)
+ })
+
+ test('usePage works without pageDesignerParams', async () => {
+ const pageData = {id: 'testPage', typeId: 'storePage'}
+ mockQueryEndpoint(experienceEndpoint, pageData)
+
+ const {result} = renderHookWithProviders(() =>
+ queries.usePage({parameters: {pageId: 'testPage'}})
+ )
+
+ await waitAndExpectSuccess(() => result.current)
+ expect(result.current.data).toEqual(pageData)
+ })
+
+ test('usePages works without pageDesignerParams', async () => {
+ const pagesData = {data: []}
+ mockQueryEndpoint(experienceEndpoint, pagesData)
+
+ const {result} = renderHookWithProviders(() =>
+ queries.usePages({parameters: {aspectTypeId: 'pdpAspect', categoryId: 'cat1'}})
+ )
+
+ await waitAndExpectSuccess(() => result.current)
+ expect(result.current.data).toEqual(pagesData)
+ })
+
+ test('usePage with partial pageDesignerParams (only mode)', async () => {
+ const pageData = {id: 'testPage', typeId: 'storePage'}
+ mockQueryEndpoint(experienceEndpoint, pageData)
+
+ const pageDesignerParams = {
+ mode: 'preview' as const
+ }
+
+ const {result} = renderHookWithProviders(
+ () => queries.usePage({parameters: {pageId: 'testPage'}}),
+ {pageDesignerParams}
+ )
+
+ await waitAndExpectSuccess(() => result.current)
+ expect(result.current.data).toEqual(pageData)
+ })
+})
diff --git a/packages/commerce-sdk-react/src/hooks/ShopperExperience/query.ts b/packages/commerce-sdk-react/src/hooks/ShopperExperience/query.ts
index 704cd7913b..d487044961 100644
--- a/packages/commerce-sdk-react/src/hooks/ShopperExperience/query.ts
+++ b/packages/commerce-sdk-react/src/hooks/ShopperExperience/query.ts
@@ -12,6 +12,7 @@ import {mergeOptions, omitNullableParameters, pickValidParams} from '../utils'
import * as queryKeyHelpers from './queryKeyHelpers'
import {CLIENT_KEYS} from '../../constant'
import useCommerceApi from '../useCommerceApi'
+import {usePageDesignerParams} from './usePageDesignerParams'
const CLIENT_KEY = CLIENT_KEYS.SHOPPER_EXPERIENCE
type Client = NonNullable
@@ -39,17 +40,41 @@ export const usePages = (
const client = useCommerceApi(CLIENT_KEY)
const methodName = 'getPages'
const requiredParameters = ShopperExperience.paramKeys[`${methodName}Required`]
+ const {mode, pdToken, pageId} = usePageDesignerParams()
+
+ // Determine if we're in Page Designer mode (edit mode or preview with token)
+ // When true, we use rawResponse to preserve all fields like designMetadata
+ const isPageDesignerMode = !!(mode || pdToken)
+
+ // Merge Page Designer params (mode, pdToken) from URL if present
+ // Note: pageId is intentionally excluded as it's not an API parameter
+ const apiOptionsWithPDParams = {
+ ...apiOptions,
+ parameters: {
+ ...apiOptions.parameters
+ }
+ }
// Parameters can be set in `apiOptions` or `client.clientConfig`;
// we must merge them in order to generate the correct query key.
- const netOptions = omitNullableParameters(mergeOptions(client, apiOptions))
- const parameters = pickValidParams(
- netOptions.parameters,
- ShopperExperience.paramKeys[methodName]
- )
+ const netOptions = omitNullableParameters(mergeOptions(client, apiOptionsWithPDParams))
+ const parameters = {
+ ...pickValidParams(netOptions.parameters, ShopperExperience.paramKeys[methodName]),
+ // Add Page Designer params after filtering - these are not officially part of the oas spec, since they are meant to be internal
+ ...(mode && {mode}),
+ ...(pdToken && {pdToken}),
+ ...(pageId && {pageId})
+ }
const queryKey = queryKeyHelpers[methodName].queryKey(netOptions.parameters)
// We don't use `netOptions` here because we manipulate the options in `useQuery`.
- const method = async (options: Options) => await client[methodName](options)
+ // When in Page Designer mode, use rawResponse: true to preserve all response fields
+ const method = async (options: Options) => {
+ if (isPageDesignerMode) {
+ const response = await client[methodName](options, true)
+ return await response.json()
+ }
+ return await client[methodName](options)
+ }
queryOptions.meta = {
displayName: 'usePages',
@@ -87,17 +112,42 @@ export const usePage = (
const client = useCommerceApi(CLIENT_KEY)
const methodName = 'getPage'
const requiredParameters = ShopperExperience.paramKeys[`${methodName}Required`]
+ const {mode, pdToken, pageId} = usePageDesignerParams()
+
+ // Determine if we're in Page Designer mode (edit mode or preview with token)
+ // When true, we use rawResponse to preserve all fields like designMetadata
+ const isPageDesignerMode = Boolean(mode || pdToken)
+
+ // Merge Page Designer params (mode, pdToken) from URL if present
+ // Note: pageId is intentionally excluded as it's not an API parameter
+ const apiOptionsWithPDParams = {
+ ...apiOptions,
+ parameters: {
+ ...apiOptions.parameters
+ }
+ }
// Parameters can be set in `apiOptions` or `client.clientConfig`;
// we must merge them in order to generate the correct query key.
- const netOptions = omitNullableParameters(mergeOptions(client, apiOptions))
- const parameters = pickValidParams(
- netOptions.parameters,
- ShopperExperience.paramKeys[methodName]
- )
+ const netOptions = omitNullableParameters(mergeOptions(client, apiOptionsWithPDParams))
+ const parameters = {
+ ...pickValidParams(netOptions.parameters, ShopperExperience.paramKeys[methodName]),
+ // Add Page Designer params after filtering - these are not officially part of the oas spec, since they are meant to be internal
+ ...(mode && {mode}),
+ ...(pdToken && {pdToken}),
+ ...(pageId && {pageId})
+ }
const queryKey = queryKeyHelpers[methodName].queryKey(netOptions.parameters)
// We don't use `netOptions` here because we manipulate the options in `useQuery`.
- const method = async (options: Options) => await client[methodName](options)
+ // When in Page Designer mode, use rawResponse: true to preserve all response fields that are not exposed at runtime
+ // to improve performance and the size of the response
+ const method = async (options: Options) => {
+ if (isPageDesignerMode) {
+ const response = await client[methodName](options, true)
+ return await response.json()
+ }
+ return await client[methodName](options)
+ }
queryOptions.meta = {
displayName: 'usePage',
diff --git a/packages/commerce-sdk-react/src/hooks/ShopperExperience/usePageDesignerParams.test.tsx b/packages/commerce-sdk-react/src/hooks/ShopperExperience/usePageDesignerParams.test.tsx
new file mode 100644
index 0000000000..c0430a9a76
--- /dev/null
+++ b/packages/commerce-sdk-react/src/hooks/ShopperExperience/usePageDesignerParams.test.tsx
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2023, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import {renderHook} from '@testing-library/react'
+import React from 'react'
+import {usePageDesignerParams} from './usePageDesignerParams'
+import CommerceApiProvider from '../../provider'
+import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
+
+const DEFAULT_CONFIG = {
+ proxy: 'http://localhost:8888/mobify/proxy/api',
+ redirectURI: 'http://localhost:8888/callback',
+ clientId: '12345678-1234-1234-1234-123412341234',
+ organizationId: 'f_ecom_zzrmy_orgf_001',
+ shortCode: '12345678',
+ siteId: 'RefArchGlobal',
+ locale: 'en-US',
+ currency: 'USD'
+}
+
+const createWrapper = (pageDesignerParams?: {
+ mode?: 'edit' | 'preview'
+ pdToken?: string
+ pageId?: string
+}) => {
+ const queryClient = new QueryClient({
+ defaultOptions: {queries: {retry: false}, mutations: {retry: false}}
+ })
+ const Wrapper = ({children}: {children: React.ReactNode}) => (
+
+
+ {children}
+
+
+ )
+ return Wrapper
+}
+
+describe('usePageDesignerParams', () => {
+ test('returns empty object when no pageDesignerParams provided', () => {
+ const {result} = renderHook(() => usePageDesignerParams(), {
+ wrapper: createWrapper()
+ })
+
+ expect(result.current).toEqual({})
+ })
+
+ test('returns mode when provided', () => {
+ const {result} = renderHook(() => usePageDesignerParams(), {
+ wrapper: createWrapper({mode: 'edit'})
+ })
+
+ expect(result.current.mode).toBe('edit')
+ })
+
+ test('returns pdToken when provided', () => {
+ const {result} = renderHook(() => usePageDesignerParams(), {
+ wrapper: createWrapper({pdToken: 'test-token'})
+ })
+
+ expect(result.current.pdToken).toBe('test-token')
+ })
+
+ test('returns pageId when provided', () => {
+ const {result} = renderHook(() => usePageDesignerParams(), {
+ wrapper: createWrapper({pageId: 'test-page-id'})
+ })
+
+ expect(result.current.pageId).toBe('test-page-id')
+ })
+
+ test('returns all params when all provided', () => {
+ const pageDesignerParams = {
+ mode: 'preview' as const,
+ pdToken: 'my-token',
+ pageId: 'my-page'
+ }
+
+ const {result} = renderHook(() => usePageDesignerParams(), {
+ wrapper: createWrapper(pageDesignerParams)
+ })
+
+ expect(result.current).toEqual(pageDesignerParams)
+ })
+
+ test('returns partial params when partially provided', () => {
+ const {result} = renderHook(() => usePageDesignerParams(), {
+ wrapper: createWrapper({mode: 'edit', pdToken: 'token'})
+ })
+
+ expect(result.current.mode).toBe('edit')
+ expect(result.current.pdToken).toBe('token')
+ expect(result.current.pageId).toBeUndefined()
+ })
+})
diff --git a/packages/commerce-sdk-react/src/hooks/ShopperExperience/usePageDesignerParams.ts b/packages/commerce-sdk-react/src/hooks/ShopperExperience/usePageDesignerParams.ts
new file mode 100644
index 0000000000..1a91cf0a8f
--- /dev/null
+++ b/packages/commerce-sdk-react/src/hooks/ShopperExperience/usePageDesignerParams.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2025, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+import useConfig from '../useConfig'
+import {PageDesignerParams} from '../../provider'
+
+/**
+ * Hook to get Page Designer query parameters (mode, pdToken).
+ * These parameters are used when previewing pages in Page Designer edit mode.
+ *
+ * The parameters should be passed to CommerceApiProvider via the `pageDesignerParams` prop,
+ * extracted from the request URL on the server side for SSR compatibility.
+ *
+ * @returns An object containing mode and pdToken if provided to the CommerceApiProvider
+ */
+export const usePageDesignerParams = (): PageDesignerParams => {
+ const config = useConfig()
+ return config.pageDesignerParams || {}
+}
diff --git a/packages/commerce-sdk-react/src/hooks/useGlobalAnchorBlock.test.ts b/packages/commerce-sdk-react/src/hooks/useGlobalAnchorBlock.test.ts
new file mode 100644
index 0000000000..8e2b25f62a
--- /dev/null
+++ b/packages/commerce-sdk-react/src/hooks/useGlobalAnchorBlock.test.ts
@@ -0,0 +1,267 @@
+/*
+ * Copyright (c) 2026, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import {renderHook} from '@testing-library/react'
+import {useGlobalAnchorBlock} from './useGlobalAnchorBlock'
+
+describe('useGlobalAnchorBlock', () => {
+ let addEventListenerSpy: jest.SpyInstance
+ let removeEventListenerSpy: jest.SpyInstance
+
+ beforeEach(() => {
+ // Create spies for event listener methods
+ addEventListenerSpy = jest.spyOn(document, 'addEventListener')
+ removeEventListenerSpy = jest.spyOn(document, 'removeEventListener')
+ })
+
+ afterEach(() => {
+ // Clean up spies
+ addEventListenerSpy.mockRestore()
+ removeEventListenerSpy.mockRestore()
+ })
+
+ describe('event listener registration', () => {
+ it('should add click event listener when enabled is true', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), true)
+ })
+
+ it('should add click event listener when enabled is not provided (defaults to true)', () => {
+ renderHook(() => useGlobalAnchorBlock())
+
+ expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), true)
+ })
+
+ it('should not add click event listener when enabled is false', () => {
+ renderHook(() => useGlobalAnchorBlock(false))
+
+ expect(addEventListenerSpy).not.toHaveBeenCalled()
+ })
+
+ it('should remove click event listener on unmount', () => {
+ const {unmount} = renderHook(() => useGlobalAnchorBlock(true))
+
+ // Get the handler function that was registered
+ const registeredHandler = addEventListenerSpy.mock.calls[0][1]
+
+ unmount()
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('click', registeredHandler, true)
+ })
+
+ it('should update event listener when enabled changes', () => {
+ const {rerender} = renderHook(({enabled}) => useGlobalAnchorBlock(enabled), {
+ initialProps: {enabled: false}
+ })
+
+ expect(addEventListenerSpy).not.toHaveBeenCalled()
+
+ // Enable blocking
+ rerender({enabled: true})
+
+ expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), true)
+
+ // Disable blocking
+ rerender({enabled: false})
+
+ expect(removeEventListenerSpy).toHaveBeenCalled()
+ })
+ })
+
+ describe('anchor click blocking', () => {
+ it('should prevent default on anchor clicks', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ const anchor = document.createElement('a')
+ anchor.href = '/some-path'
+ document.body.appendChild(anchor)
+
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
+
+ anchor.dispatchEvent(event)
+
+ expect(preventDefaultSpy).toHaveBeenCalled()
+
+ document.body.removeChild(anchor)
+ })
+
+ it('should prevent default when clicking on child elements of anchor', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ const anchor = document.createElement('a')
+ anchor.href = '/some-path'
+ const span = document.createElement('span')
+ span.textContent = 'Click me'
+ anchor.appendChild(span)
+ document.body.appendChild(anchor)
+
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
+
+ // Click the span inside the anchor
+ span.dispatchEvent(event)
+
+ expect(preventDefaultSpy).toHaveBeenCalled()
+
+ document.body.removeChild(anchor)
+ })
+
+ it('should not prevent default on non-anchor elements', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ const button = document.createElement('button')
+ document.body.appendChild(button)
+
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
+
+ button.dispatchEvent(event)
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled()
+
+ document.body.removeChild(button)
+ })
+ })
+
+ describe('data-pd-allow-link attribute', () => {
+ it('should allow navigation when anchor has data-pd-allow-link attribute', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ const anchor = document.createElement('a')
+ anchor.href = '/some-path'
+ anchor.setAttribute('data-pd-allow-link', '')
+ document.body.appendChild(anchor)
+
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
+
+ anchor.dispatchEvent(event)
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled()
+
+ document.body.removeChild(anchor)
+ })
+
+ it('should allow navigation when clicking child of anchor with data-pd-allow-link', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ const anchor = document.createElement('a')
+ anchor.href = '/some-path'
+ anchor.setAttribute('data-pd-allow-link', '')
+ const span = document.createElement('span')
+ span.textContent = 'Click me'
+ anchor.appendChild(span)
+ document.body.appendChild(anchor)
+
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
+
+ span.dispatchEvent(event)
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled()
+
+ document.body.removeChild(anchor)
+ })
+ })
+
+ describe('hash/anchor links', () => {
+ it('should allow navigation for hash links (#section)', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ const anchor = document.createElement('a')
+ anchor.href = '#section'
+ document.body.appendChild(anchor)
+
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
+
+ anchor.dispatchEvent(event)
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled()
+
+ document.body.removeChild(anchor)
+ })
+
+ it('should allow navigation for empty hash links (#)', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ const anchor = document.createElement('a')
+ anchor.href = '#'
+ document.body.appendChild(anchor)
+
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
+
+ anchor.dispatchEvent(event)
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled()
+
+ document.body.removeChild(anchor)
+ })
+
+ it('should block navigation for regular paths, not hash links', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ const anchor = document.createElement('a')
+ anchor.href = '/path/to/page'
+ document.body.appendChild(anchor)
+
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
+
+ anchor.dispatchEvent(event)
+
+ expect(preventDefaultSpy).toHaveBeenCalled()
+
+ document.body.removeChild(anchor)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle anchors without href attribute', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ const anchor = document.createElement('a')
+ // No href attribute
+ document.body.appendChild(anchor)
+
+ const event = new MouseEvent('click', {bubbles: true, cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
+
+ anchor.dispatchEvent(event)
+
+ // Should prevent default even without href
+ expect(preventDefaultSpy).toHaveBeenCalled()
+
+ document.body.removeChild(anchor)
+ })
+
+ it('should handle multiple hook instances', () => {
+ renderHook(() => useGlobalAnchorBlock(true))
+ renderHook(() => useGlobalAnchorBlock(true))
+
+ // Should have registered two event listeners
+ expect(addEventListenerSpy).toHaveBeenCalledTimes(2)
+ })
+
+ it('should handle rapid enable/disable toggling', () => {
+ const {rerender} = renderHook(({enabled}) => useGlobalAnchorBlock(enabled), {
+ initialProps: {enabled: true}
+ })
+
+ rerender({enabled: false})
+ rerender({enabled: true})
+ rerender({enabled: false})
+ rerender({enabled: true})
+
+ // Should have added and removed listeners appropriately
+ expect(addEventListenerSpy.mock.calls.length).toBeGreaterThan(0)
+ expect(removeEventListenerSpy.mock.calls.length).toBeGreaterThan(0)
+ })
+ })
+})
diff --git a/packages/commerce-sdk-react/src/hooks/useGlobalAnchorBlock.ts b/packages/commerce-sdk-react/src/hooks/useGlobalAnchorBlock.ts
new file mode 100644
index 0000000000..32b2a14f67
--- /dev/null
+++ b/packages/commerce-sdk-react/src/hooks/useGlobalAnchorBlock.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2026, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+import {useEffect} from 'react'
+
+/**
+ * React hook that prevents all (anchor) navigation by default in the document,
+ * unless the anchor has the attribute `data-pd-allow-link`.
+ *
+ * This is used in Page Designer design mode to prevent accidental navigation
+ * when clicking on links within the preview iframe.
+ *
+ * To allow a specific link to navigate even in design mode, add the attribute:
+ * Navigable Link
+ *
+ * @param {boolean} enabled - Whether to block navigation. Defaults to true.
+ *
+ * @example
+ * ```jsx
+ * function PageDesignerInit() {
+ * const { isDesignMode } = usePageDesignerMode();
+ * useGlobalAnchorBlock(isDesignMode);
+ * return null;
+ * }
+ * ```
+ */
+export function useGlobalAnchorBlock(enabled = true) {
+ useEffect(() => {
+ // Only run on client-side
+ if (typeof window === 'undefined' || !enabled) {
+ return
+ }
+
+ function preventAnchorClicks(event: MouseEvent) {
+ const target = event.target as HTMLElement
+ const anchor = target.closest('a')
+
+ if (!anchor) {
+ return
+ }
+
+ // Allow links with data-pd-allow-link attribute
+ if (anchor.hasAttribute('data-pd-allow-link')) {
+ return
+ }
+
+ const href = anchor.getAttribute('href')
+
+ // Allow hash/anchor links (e.g., #section) as they don't navigate away
+ if (href && href.startsWith('#')) {
+ return
+ }
+
+ // Block all other navigation in design mode
+ event.preventDefault()
+ }
+
+ // Use capture phase to intercept clicks before React Router's handlers
+ document.addEventListener('click', preventAnchorClicks, true)
+
+ return () => document.removeEventListener('click', preventAnchorClicks, true)
+ }, [enabled])
+}
diff --git a/packages/commerce-sdk-react/src/hooks/useUsid.test.ts b/packages/commerce-sdk-react/src/hooks/useUsid.test.ts
new file mode 100644
index 0000000000..fd4d1c3d33
--- /dev/null
+++ b/packages/commerce-sdk-react/src/hooks/useUsid.test.ts
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2025, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import useUsid from './useUsid'
+import useAuthContext from './useAuthContext'
+
+jest.mock('./useAuthContext')
+
+const mockedUseAuthContext = useAuthContext as jest.MockedFunction
+
+describe('useUsid', () => {
+ const mockUsid = 'test-usid-12345'
+
+ beforeEach(() => {
+ jest.resetAllMocks()
+
+ // Mock the auth context to properly handle calls to ready() and get()
+ mockedUseAuthContext.mockReturnValue({
+ ready: jest.fn().mockImplementation(() => {
+ return Promise.resolve({usid: mockUsid})
+ }),
+ get: jest.fn().mockImplementation((key) => {
+ if (key === 'usid') return mockUsid
+ return null
+ })
+ } as any)
+ })
+
+ describe('usid', () => {
+ it('returns usid from auth.get()', () => {
+ const {usid} = useUsid()
+
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ const mockGet = mockedUseAuthContext().get
+ expect(mockGet).toHaveBeenCalledWith('usid')
+ expect(usid).toBe(mockUsid)
+ })
+
+ it('returns null when auth.get returns null', () => {
+ mockedUseAuthContext.mockReturnValue({
+ ready: jest.fn().mockResolvedValue({usid: mockUsid}),
+ get: jest.fn().mockReturnValue(null)
+ } as any)
+
+ const {usid} = useUsid()
+
+ expect(usid).toBeNull()
+ })
+ })
+
+ describe('getUsidWhenReady', () => {
+ it('calls auth.ready() and returns the usid', async () => {
+ const {getUsidWhenReady} = useUsid()
+
+ const result = await getUsidWhenReady()
+
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ const mockReady = mockedUseAuthContext().ready
+ expect(mockReady).toHaveBeenCalled()
+ expect(result).toBe(mockUsid)
+ })
+
+ it('does not call auth.ready() immediately upon hook initialization', () => {
+ useUsid()
+
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ const mockReady = mockedUseAuthContext().ready
+ expect(mockReady).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/commerce-sdk-react/src/page-designer/index.ts b/packages/commerce-sdk-react/src/page-designer/index.ts
new file mode 100644
index 0000000000..ace094c0c0
--- /dev/null
+++ b/packages/commerce-sdk-react/src/page-designer/index.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2026, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+/**
+ * Page Designer Integration Module
+ *
+ * This module provides components, hooks, and utilities for integrating
+ * Salesforce Page Designer with React applications. Import from this
+ * subpath only if you're using Page Designer features.
+ *
+ * @example
+ * ```typescript
+ * // Import Page Designer features
+ * import {
+ * PageDesignerProvider,
+ * usePageDesignerMode,
+ * Page,
+ * usePages
+ * } from '@salesforce/commerce-sdk-react/page-designer'
+ * ```
+ *
+ * @module page-designer
+ */
+
+// Re-export ShopperExperience components (Page Designer rendering)
+export * from '../components/ShopperExperienceV2'
+
+// Re-export Page Designer hooks
+export {useGlobalAnchorBlock} from '../hooks/useGlobalAnchorBlock'
+
+// Re-export the registry for component registration
+export {registry} from '../components/ShopperExperienceV2'
diff --git a/packages/commerce-sdk-react/src/provider.tsx b/packages/commerce-sdk-react/src/provider.tsx
index 70f4305171..99eaad6c0a 100644
--- a/packages/commerce-sdk-react/src/provider.tsx
+++ b/packages/commerce-sdk-react/src/provider.tsx
@@ -30,6 +30,12 @@ import {
} from 'commerce-sdk-isomorphic'
import {transformSDKClient} from './utils'
+export interface PageDesignerParams {
+ mode?: string
+ pdToken?: string
+ pageId?: string
+}
+
export interface CommerceApiProviderProps extends ApiClientConfigParams {
children: React.ReactNode
proxy: string
@@ -51,6 +57,7 @@ export interface CommerceApiProviderProps extends ApiClientConfigParams {
apiClients?: ApiClients
disableAuthInit?: boolean
hybridAuthEnabled?: boolean
+ pageDesignerParams?: PageDesignerParams
}
/**
@@ -148,7 +155,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
refreshTokenGuestCookieTTL,
apiClients,
disableAuthInit = false,
- hybridAuthEnabled = false
+ hybridAuthEnabled = false,
+ pageDesignerParams = {}
} = props
// Set the logger based on provided configuration, or default to the console object if no logger is provided
@@ -317,7 +325,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
defaultDnt,
passwordlessLoginCallbackURI,
refreshTokenRegisteredCookieTTL,
- refreshTokenGuestCookieTTL
+ refreshTokenGuestCookieTTL,
+ pageDesignerParams
}}
>
diff --git a/packages/commerce-sdk-react/tsconfig.json b/packages/commerce-sdk-react/tsconfig.json
index 8823368f9a..f48a4bc5c8 100644
--- a/packages/commerce-sdk-react/tsconfig.json
+++ b/packages/commerce-sdk-react/tsconfig.json
@@ -27,8 +27,14 @@
"module": "esnext" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
- // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
- // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
+ "baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */,
+ "paths": {
+ "@salesforce/storefront-next-runtime/design/react/core": ["node_modules/@salesforce/storefront-next-runtime/dist/design-react-core.d.ts"],
+ "@salesforce/storefront-next-runtime/design/react": ["node_modules/@salesforce/storefront-next-runtime/dist/design-react.d.ts"],
+ "@salesforce/storefront-next-runtime/design": ["node_modules/@salesforce/storefront-next-runtime/dist/design.d.ts"],
+ "@salesforce/storefront-next-runtime/design/mode": ["node_modules/@salesforce/storefront-next-runtime/dist/design-mode.d.ts"],
+ "@salesforce/storefront-next-runtime/scapi": ["node_modules/@salesforce/storefront-next-runtime/dist/scapi.d.ts"]
+ } /* Specify a set of entries that re-map imports to additional lookup locations. */,
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
diff --git a/packages/pwa-kit-dev/CHANGELOG.md b/packages/pwa-kit-dev/CHANGELOG.md
index 7a1d0f76b7..487c3d09fc 100644
--- a/packages/pwa-kit-dev/CHANGELOG.md
+++ b/packages/pwa-kit-dev/CHANGELOG.md
@@ -1,4 +1,5 @@
## v3.17.0-dev
+- Add Page Designer Design CSS Support
- Update jest, archiver and remove rimraf dependencies [#3663](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3663)
- Add Node 24 support, remove legacy `url` module import. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
diff --git a/packages/pwa-kit-dev/src/configs/webpack/config.js b/packages/pwa-kit-dev/src/configs/webpack/config.js
index f4888d0233..642e808b8d 100644
--- a/packages/pwa-kit-dev/src/configs/webpack/config.js
+++ b/packages/pwa-kit-dev/src/configs/webpack/config.js
@@ -202,6 +202,13 @@ const baseConfig = (target) => {
]
}
: {}),
+ conditionNames: [
+ 'import',
+ 'require',
+ 'module',
+ ...(target === 'web' ? ['browser'] : ['node']),
+ 'default'
+ ],
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
alias: {
...Object.assign(
@@ -272,6 +279,10 @@ const baseConfig = (target) => {
use: {
loader: findDepInStack('source-map-loader')
}
+ },
+ {
+ test: /\.css$/,
+ loader: findDepInStack('ignore-loader')
}
].filter(Boolean)
}
@@ -343,6 +354,15 @@ const staticFolderCopyPlugin = new CopyPlugin({
.replace(/\\/g, '/'),
to: `static/`,
noErrorOnMissing: true
+ },
+ {
+ // Copy Page Designer CSS from storefront-next-runtime for dynamic loading
+ from: path.resolve(
+ projectDir,
+ 'node_modules/@salesforce/storefront-next-runtime/dist/design-styles.css'
+ ),
+ to: 'static/pd-design-styles.css',
+ noErrorOnMissing: true
}
]
})
diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md
index b35b457380..426b131e65 100644
--- a/packages/template-retail-react-app/CHANGELOG.md
+++ b/packages/template-retail-react-app/CHANGELOG.md
@@ -1,4 +1,5 @@
## v9.1.0-dev
+- Add Page Designer Support
- [Feature] Add Salesforce Payments support in checkout
- Update jest-fetch-mock and Jest 29 dependencies [#3663](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3663)
- Add Node 24 support. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
diff --git a/packages/template-retail-react-app/app/components/_app-config/index.jsx b/packages/template-retail-react-app/app/components/_app-config/index.jsx
index 7880c28341..06efaebbee 100644
--- a/packages/template-retail-react-app/app/components/_app-config/index.jsx
+++ b/packages/template-retail-react-app/app/components/_app-config/index.jsx
@@ -25,7 +25,8 @@ import {MultiSiteProvider, StoreLocatorProvider} from '@salesforce/retail-react-
import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
import {
resolveSiteFromUrl,
- resolveLocaleFromUrl
+ resolveLocaleFromUrl,
+ resolvePageDesignerParamsFromUrl
} from '@salesforce/retail-react-app/app/utils/site-utils'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {
@@ -92,6 +93,8 @@ const AppConfig = ({children, locals = {}}) => {
const proxy = `${appOrigin}${getEnvBasePath()}${commerceApiConfig.proxyPath}`
const slasPrivateClientProxyEndpoint = `${appOrigin}${getEnvBasePath()}${slasPrivateProxyPath}`
+ const pageDesignerParams = locals.pageDesignerParams || {}
+
return (
{
// Uncomment 'hybridAuthEnabled' if the current site has Hybrid Auth enabled. Do NOT set this flag for hybrid storefronts using Plugin SLAS.
// hybridAuthEnabled={true}
logger={createLogger({packageName: 'commerce-sdk-react'})}
+ pageDesignerParams={pageDesignerParams}
>
@@ -140,10 +144,13 @@ AppConfig.restore = (locals = {}) => {
apiConfig.parameters.siteId = site.id
+ const pageDesignerParams = resolvePageDesignerParamsFromUrl(path)
+
locals.buildUrl = createUrlTemplate(appConfig, site.alias || site.id, locale.id)
locals.site = site
locals.locale = locale
locals.appConfig = appConfig
+ locals.pageDesignerParams = pageDesignerParams
}
AppConfig.freeze = () => undefined
diff --git a/packages/template-retail-react-app/app/components/_app/index.jsx b/packages/template-retail-react-app/app/components/_app/index.jsx
index 8ef320b923..3eaa907578 100644
--- a/packages/template-retail-react-app/app/components/_app/index.jsx
+++ b/packages/template-retail-react-app/app/components/_app/index.jsx
@@ -5,23 +5,26 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
-import React, {useState, useEffect, useMemo} from 'react'
-import PropTypes from 'prop-types'
-import {useHistory, useLocation} from 'react-router-dom'
-import {StorefrontPreview} from '@salesforce/commerce-sdk-react/components'
-import {getAssetUrl} from '@salesforce/pwa-kit-react-sdk/ssr/universal/utils'
-import useActiveData from '@salesforce/retail-react-app/app/hooks/use-active-data'
-import {useQuery} from '@tanstack/react-query'
import {
useAccessToken,
useCategory,
- useShopperBasketsV2Mutation as useShopperBasketsMutation
+ useShopperBasketsMutation,
+ useUsid
} from '@salesforce/commerce-sdk-react'
-import logger from '@salesforce/retail-react-app/app/utils/logger-instance'
-import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
+import {StorefrontPreview} from '@salesforce/commerce-sdk-react/components'
+import {getAssetUrl} from '@salesforce/pwa-kit-react-sdk/ssr/universal/utils'
+import {useServerContext} from '@salesforce/pwa-kit-react-sdk/ssr/universal/hooks'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
+import useActiveData from '@salesforce/retail-react-app/app/hooks/use-active-data'
+import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
+import logger from '@salesforce/retail-react-app/app/utils/logger-instance'
+import {useQuery} from '@tanstack/react-query'
+import PropTypes from 'prop-types'
+import React, {useEffect, useMemo, useState} from 'react'
+import {useHistory, useLocation} from 'react-router-dom'
// Chakra
+import {SkipNavContent, SkipNavLink} from '@chakra-ui/skip-nav'
import {
Box,
Center,
@@ -30,63 +33,69 @@ import {
useDisclosure,
useStyleConfig
} from '@salesforce/retail-react-app/app/components/shared/ui'
-import {SkipNavLink, SkipNavContent} from '@chakra-ui/skip-nav'
// Contexts
import {CurrencyProvider} from '@salesforce/retail-react-app/app/contexts'
// Local Project Components
+import AboveHeader from '@salesforce/retail-react-app/app/components/_app/partials/above-header'
+import {DrawerMenu} from '@salesforce/retail-react-app/app/components/drawer-menu'
+import Footer from '@salesforce/retail-react-app/app/components/footer'
import Header from '@salesforce/retail-react-app/app/components/header'
+import Island from '@salesforce/retail-react-app/app/components/island'
+import {ListMenu, ListMenuContent} from '@salesforce/retail-react-app/app/components/list-menu'
import OfflineBanner from '@salesforce/retail-react-app/app/components/offline-banner'
import OfflineBoundary from '@salesforce/retail-react-app/app/components/offline-boundary'
-import ScrollToTop from '@salesforce/retail-react-app/app/components/scroll-to-top'
-import Footer from '@salesforce/retail-react-app/app/components/footer'
-import CheckoutHeader from '@salesforce/retail-react-app/app/pages/checkout/partials/checkout-header'
-import CheckoutFooter from '@salesforce/retail-react-app/app/pages/checkout/partials/checkout-footer'
-import {DrawerMenu} from '@salesforce/retail-react-app/app/components/drawer-menu'
-import {ListMenu, ListMenuContent} from '@salesforce/retail-react-app/app/components/list-menu'
import {HideOnDesktop, HideOnMobile} from '@salesforce/retail-react-app/app/components/responsive'
-import AboveHeader from '@salesforce/retail-react-app/app/components/_app/partials/above-header'
+import ScrollToTop from '@salesforce/retail-react-app/app/components/scroll-to-top'
import {StoreLocatorModal} from '@salesforce/retail-react-app/app/components/store-locator'
-import Island from '@salesforce/retail-react-app/app/components/island'
+import CheckoutFooter from '@salesforce/retail-react-app/app/pages/checkout/partials/checkout-footer'
+import CheckoutHeader from '@salesforce/retail-react-app/app/pages/checkout/partials/checkout-header'
// Hooks
+import {AddToCartModalProvider} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal'
import {AuthModal, useAuthModal} from '@salesforce/retail-react-app/app/hooks/use-auth-modal'
-import {useStoreLocatorModal} from '@salesforce/retail-react-app/app/hooks/use-store-locator'
+import {BonusProductSelectionModalProvider} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-selection-modal'
+import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
+import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
import {
DntNotification,
useDntNotification
} from '@salesforce/retail-react-app/app/hooks/use-dnt-notification'
-import {AddToCartModalProvider} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal'
-import {BonusProductSelectionModalProvider} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-selection-modal'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
-import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
-import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
+import {useStoreLocatorModal} from '@salesforce/retail-react-app/app/hooks/use-store-locator'
import {useUpdateShopperContext} from '@salesforce/retail-react-app/app/hooks/use-update-shopper-context'
// HOCs
import {withCommerceSdkReact} from '@salesforce/retail-react-app/app/components/with-commerce-sdk-react/with-commerce-sdk-react'
+import {PageDesignerProvider} from '@salesforce/commerce-sdk-react/page-designer'
+import PageDesignerInit from '@salesforce/retail-react-app/app/components/page-designer-init'
+
// Localization
import {IntlProvider} from 'react-intl'
// Others
-import {watchOnlineStatus, flatten, isServer} from '@salesforce/retail-react-app/app/utils/utils'
-import {getTargetLocale, fetchTranslations} from '@salesforce/retail-react-app/app/utils/locale'
import {
- DEFAULT_SITE_TITLE,
- HOME_HREF,
- THEME_COLOR,
CAT_MENU_DEFAULT_NAV_SSR_DEPTH,
CAT_MENU_DEFAULT_ROOT_CATEGORY,
DEFAULT_LOCALE,
- STORE_LOCATOR_IS_ENABLED
+ DEFAULT_SITE_TITLE,
+ HOME_HREF,
+ STORE_LOCATOR_IS_ENABLED,
+ THEME_COLOR
} from '@salesforce/retail-react-app/app/constants'
+import {fetchTranslations, getTargetLocale} from '@salesforce/retail-react-app/app/utils/locale'
+import {flatten, isServer, watchOnlineStatus} from '@salesforce/retail-react-app/app/utils/utils'
import Seo from '@salesforce/retail-react-app/app/components/seo'
import ShopperAgent from '@salesforce/retail-react-app/app/components/shopper-agent'
-import {getPathWithLocale} from '@salesforce/retail-react-app/app/utils/url'
+import {initializeRegistry} from '@salesforce/retail-react-app/app/page-designer/registry'
+
+// Initialize registry synchronously at module load time so components are available during SSR
+initializeRegistry()
import {getCommerceAgentConfig} from '@salesforce/retail-react-app/app/utils/config-utils'
+import {getPathWithLocale} from '@salesforce/retail-react-app/app/utils/url'
import {useShopperAgent} from '@salesforce/retail-react-app/app/hooks/use-shopper-agent'
const PlaceholderComponent = () => (
@@ -137,6 +146,7 @@ const App = (props) => {
})
const categories = flatten(categoriesTree || {}, 'categories')
const {getTokenWhenReady} = useAccessToken()
+ const {usid} = useUsid()
const appOrigin = useAppOrigin()
const activeData = useActiveData()
const history = useHistory()
@@ -150,11 +160,20 @@ const App = (props) => {
onClose: onCloseStoreLocator
} = useStoreLocatorModal()
const storeLocatorEnabled = getConfig()?.app?.storeLocatorEnabled ?? STORE_LOCATOR_IS_ENABLED
+ const {req} = useServerContext()
const [isOnline, setIsOnline] = useState(true)
const styles = useStyleConfig('App')
const {isOpen, onOpen, onClose} = useDisclosure()
+ // Determine Page Designer mode from URL - use req for server-side detection
+ const pageDesignerMode = useMemo(() => {
+ const queryParams = location?.search || ''
+ if (queryParams.includes('mode=EDIT')) return 'EDIT'
+ else if (queryParams.includes('mode=PREVIEW')) return 'PREVIEW'
+ return undefined
+ }, [req?.url])
+
const targetLocale = getTargetLocale({
getUserPreferredLocales: () => {
// CONFIG: This function should return an array of preferred locales. They can be
@@ -453,7 +472,15 @@ const App = (props) => {
flex="1"
>
- {children}
+
+
+ {children}
+
diff --git a/packages/template-retail-react-app/app/components/_app/index.test.js b/packages/template-retail-react-app/app/components/_app/index.test.js
index a3ee19e0a3..9af0aa1db5 100644
--- a/packages/template-retail-react-app/app/components/_app/index.test.js
+++ b/packages/template-retail-react-app/app/components/_app/index.test.js
@@ -23,15 +23,30 @@ jest.mock('../../hooks/use-multi-site', () => jest.fn())
jest.mock('../../hooks/use-update-shopper-context', () => ({
useUpdateShopperContext: jest.fn()
}))
+jest.mock('../../page-designer/registry', () => ({
+ initializeRegistry: jest.fn()
+}))
let windowSpy
const mockUpdateDnt = jest.fn()
+
+// Mock registry with required methods
+const mockRegistry = {
+ registerImporter: jest.fn(),
+ getComponent: jest.fn(),
+ has: jest.fn(() => false),
+ get: jest.fn()
+}
+
jest.mock('@salesforce/commerce-sdk-react', () => {
const originalModule = jest.requireActual('@salesforce/commerce-sdk-react')
return {
...originalModule,
- useDNT: () => ({selectedDnt: undefined, updateDnt: mockUpdateDnt})
+ useDNT: () => ({selectedDnt: undefined, updateDnt: mockUpdateDnt}),
+ useUsid: () => ({usid: 'test-usid', getUsidWhenReady: () => Promise.resolve('test-usid')}),
+ useGlobalAnchorBlock: jest.fn(),
+ registry: mockRegistry
}
})
diff --git a/packages/template-retail-react-app/app/components/page-designer-init/index.jsx b/packages/template-retail-react-app/app/components/page-designer-init/index.jsx
new file mode 100644
index 0000000000..9c046fedc3
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/page-designer-init/index.jsx
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2026, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+import React, {useEffect} from 'react'
+import {Prompt} from 'react-router-dom'
+import {
+ usePageDesignerMode,
+ useGlobalAnchorBlock
+} from '@salesforce/commerce-sdk-react/page-designer'
+import {getAssetUrl} from '@salesforce/pwa-kit-react-sdk/ssr/universal/utils'
+
+/**
+ * PageDesignerInit - Component that handles Page Designer initialization
+ *
+ * This component is responsible for:
+ * 1. Blocking navigation in React Router when in Page Designer design mode
+ * 2. Preventing anchor clicks from navigating (using useGlobalAnchorBlock)
+ * 3. Dynamically importing Page Designer styles when in design mode
+ *
+ * The component prevents accidental navigation during content editing in Page Designer,
+ * ensuring that content managers can click on links within the preview without leaving
+ * the editing interface.
+ *
+ * Required URL Parameters (automatically detected):
+ * - mode=EDIT - Activates design mode for editing content in Page Designer
+ * - mode=PREVIEW - Activates preview mode for previewing content
+ * - pdToken - Authentication token for Page Designer API requests (optional)
+ * - pageId - Specific page ID to load in Page Designer (optional)
+ *
+ * @example
+ * ```jsx
+ * // In your App component
+ * import PageDesignerInit from './components/page-designer-init'
+ *
+ * function App() {
+ * return (
+ *
+ *
+ *
+ *
+ * )
+ * }
+ * ```
+ *
+ * @example
+ * ```
+ * // Example URLs:
+ * https://example.com/?mode=EDIT&pdToken=abc123&pageId=homepage
+ * https://example.com/?mode=PREVIEW&pdToken=xyz789
+ * ```
+ */
+export function PageDesignerInit() {
+ const {isDesignMode} = usePageDesignerMode()
+
+ // Block anchor navigation when in design mode
+ // Pass isDesignMode to control when navigation blocking is active
+ useGlobalAnchorBlock(isDesignMode)
+
+ // Dynamically load the Page Designer global styles only when in design mode.
+ // This ensures the styles are not loaded in production runtime, improving performance.
+ useEffect(() => {
+ if (!isDesignMode) {
+ return
+ }
+
+ const id = 'pd-design-styles'
+ if (document.getElementById(id)) {
+ return
+ }
+
+ const link = document.createElement('link')
+ link.id = id
+ link.rel = 'stylesheet'
+ link.href = getAssetUrl('static/pd-design-styles.css')
+ document.head.appendChild(link)
+
+ return () => link.remove()
+ }, [isDesignMode])
+
+ // When the message function returns false, navigation is completely blocked (no dialog shown)
+ return (
+
+ false} />
+
+ )
+}
+
+export default PageDesignerInit
diff --git a/packages/template-retail-react-app/app/mocks/empty-mock.js b/packages/template-retail-react-app/app/mocks/empty-mock.js
new file mode 100644
index 0000000000..d36441b94b
--- /dev/null
+++ b/packages/template-retail-react-app/app/mocks/empty-mock.js
@@ -0,0 +1,11 @@
+/*
+ * Copyright (c) 2025, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+/**
+ * Empty mock for CSS and other non-JS imports in tests
+ */
+module.exports = {}
diff --git a/packages/template-retail-react-app/app/mocks/page-designer.js b/packages/template-retail-react-app/app/mocks/page-designer.js
index 0a2c1387d3..7ba0c75e98 100644
--- a/packages/template-retail-react-app/app/mocks/page-designer.js
+++ b/packages/template-retail-react-app/app/mocks/page-designer.js
@@ -9,18 +9,15 @@ export const mockImageWithText = {
ITCText:
'Text Below Image test Image With Text Component Home-page example link
',
image: {
- _type: 'Image',
- focalPoint: {
- _type: 'Imagefocalpoint',
+ path: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Library-Sites-RefArchSharedLibrary/default/dw34c389b5/images/SearchBanner/search.jpg',
+ focal_point: {
x: 0.5,
y: 0.5
},
- metaData: {
- _type: 'Imagemetadata',
+ meta_data: {
height: 1280,
width: 1920
- },
- url: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Library-Sites-RefArchSharedLibrary/default/dw34c389b5/images/SearchBanner/search.jpg'
+ }
},
heading:
'Text Overlay test Image With Text Component Link
',
diff --git a/packages/template-retail-react-app/app/page-designer/README.md b/packages/template-retail-react-app/app/page-designer/README.md
index 1e793e454a..ebd9d8d5d0 100644
--- a/packages/template-retail-react-app/app/page-designer/README.md
+++ b/packages/template-retail-react-app/app/page-designer/README.md
@@ -1,102 +1,70 @@
- ____ ____ _
- / __ \____ _____ ____ / __ \___ _____(_)___ _____ ___ _____
- / /_/ / __ `/ __ `/ _ \ / / / / _ \/ ___/ / __ `/ __ \/ _ \/ ___/
- / ____/ /_/ / /_/ / __/ / /_/ / __(__ ) / /_/ / / / / __/ /
- /_/ \__,_/\__, /\___/ /_____/\___/____/_/\__, /_/ /_/\___/_/
- /____/ /____/
+# Page Designer Components
----
+This folder contains **pure React components** for rendering [Page Designer](https://documentation.b2c.commercecloud.salesforce.com/DOC2/topic/com.demandware.dochelp/content/b2c_commerce/topics/page_designer/b2c_creating_pd_pages.html) pages. **No ISML templates required.**
-This folder contains the React components used when rendering the pages from [Page Designer](https://documentation.b2c.commercecloud.salesforce.com/DOC2/topic/com.demandware.dochelp/content/b2c_commerce/topics/page_designer/b2c_creating_pd_pages.html).
+## Features
-Use this folder to add React components that can render Page Designer components that have been serialized to JSON.
+- **Pure React** - No ISML dependencies
+- **Visual Editing** - Edit pages in Business Manager's Page Designer interface
+- **Lazy Loading** - Components load on demand via the registry
+- **Type Safe** - Full TypeScript support
+- **Performance** - Automatic code splitting and optimization
-> NOTE: If you are creating components that do not already exist in Page Designer, follow [this](https://documentation.b2c.commercecloud.salesforce.com/DOC1/index.jsp) guide to first create your Page Designer components before creating their matching PWA-Kit React components.
+## Documentation
-This folder includes components for layout and visualization of images, grids, and carousels.
+- **[Migration Guide](../../../commerce-sdk-react/PAGE_DESIGNER.md)** - Step-by-step migration from old to new API
+- **[Concepts](../../../commerce-sdk-react/PAGE_DESIGNER_ARCHITECTURE.md)** - How Page Designer integration works
-**By default, Page Designer integration is not enabled in the Retail React App.** Additionally, to utilize the `shopperExperience`
-API used in the example below your commerce API client (SLAS client) needs to include the `sfcc.shopper-experience` scope.
-See [Authorization for Shopper APIs](https://developer.salesforce.com/docs/commerce/commerce-api/guide/authorization-for-shopper-apis.html)
-for more information on configuring your SLAS client.
+## Prerequisites
-## Folder Structure
+1. **SLAS Client**: Must include `sfcc.shopper-experience` scope. See [Authorization for Shopper APIs](https://developer.salesforce.com/docs/commerce/commerce-api/guide/authorization-for-shopper-apis.html).
+2. **Registry**: Initialize in your app (already done in `_app/index.jsx`)
-- **`/assets`** - Visual components used in Page Designer. Includes `` and `` as well as any other Page Designer assets that you want to use in your PWA-Kit app. If you need to visualize a component, add it here.
-- **`/layouts`** - Components responsible for layout. Includes various grids and a `` component.
+## Folder Structure
-## Sample Usage
+- **`/assets`** - Visual components (Image, ImageWithText, ProductTile, etc.)
+- **`/layouts`** - Layout components (grids, Carousel)
-Create a new file called `app/pages/page-viewer/index.jsx`, and add the following:
+## Quick Example
```jsx
// app/pages/page-viewer/index.jsx
-
import React from 'react'
import {useParams} from 'react-router-dom'
import {Box} from '@salesforce/retail-react-app/app/components/shared/ui'
import {usePage} from '@salesforce/commerce-sdk-react'
import {Page} from '@salesforce/commerce-sdk-react/components'
-import {ImageTile, ImageWithText} from '../../page-designer/assets'
-import {
- Carousel,
- MobileGrid1r1c,
- MobileGrid2r1c,
- MobileGrid2r2c,
- MobileGrid2r3c,
- MobileGrid3r1c,
- MobileGrid3r2c
-} from '../../page-designer/layouts'
-
import {HTTPError, HTTPNotFound} from '@salesforce/pwa-kit-react-sdk/ssr/universal/errors'
-const PAGEDESIGNER_TO_COMPONENT = {
- 'commerce_assets.photoTile': ImageTile,
- 'commerce_assets.imageAndText': ImageWithText,
- 'commerce_layouts.carousel': Carousel,
- 'commerce_layouts.mobileGrid1r1c': MobileGrid1r1c,
- 'commerce_layouts.mobileGrid2r1c': MobileGrid2r1c,
- 'commerce_layouts.mobileGrid2r2c': MobileGrid2r2c,
- 'commerce_layouts.mobileGrid2r3c': MobileGrid2r3c,
- 'commerce_layouts.mobileGrid3r1c': MobileGrid3r1c,
- 'commerce_layouts.mobileGrid3r2c': MobileGrid3r2c
-}
-
const PageViewer = () => {
const {pageId} = useParams()
const {data: page, error} = usePage({parameters: {pageId}})
if (error) {
- let ErrorClass = error.response?.status === 404 ? HTTPNotFound : HTTPError
+ const ErrorClass = error.response?.status === 404 ? HTTPNotFound : HTTPError
throw new ErrorClass(error.response?.statusText)
}
return (
-
+
)
}
-PageViewer.displayName = 'PageViewer'
-
export default PageViewer
```
-Open `app/routes.jsx` and add a route for ``:
-
-```diff
-// app/routes.jsx
+Add route in `app/routes.jsx`:
-// Create a loadable page for `page-viewer`.
-+ const PageViewer = loadable(() => import('./pages/page-viewer'), {fallback})
+```javascript
+const PageViewer = loadable(() => import('./pages/page-viewer'), {fallback})
-
-// Add a route.
-+ {
-+ path: '/page-viewer/:pageId',
-+ component: PageViewer
-+ },
+// In routes array:
+{
+ path: '/page-viewer/:pageId',
+ component: PageViewer
+}
```
-Using the local development server, you can now see Page Designer pages rendered in React.js at `http://localhost:3000/page-viewer/:pageid` by providing their `pageid` defined in Business Manager.
+Visit `http://localhost:3000/page-viewer/:pageId` to render Page Designer pages.
diff --git a/packages/template-retail-react-app/app/page-designer/assets/image-tile/index.test.js b/packages/template-retail-react-app/app/page-designer/assets/image-tile/index.test.js
index 7ff3e9b1d9..6fc7f9a962 100644
--- a/packages/template-retail-react-app/app/page-designer/assets/image-tile/index.test.js
+++ b/packages/template-retail-react-app/app/page-designer/assets/image-tile/index.test.js
@@ -13,13 +13,11 @@ test('ImageTile renders without errors', () => {
const {getByTestId} = renderWithProviders(
)
diff --git a/packages/template-retail-react-app/app/page-designer/index.js b/packages/template-retail-react-app/app/page-designer/index.js
index 31e8468e2b..623d9300d8 100644
--- a/packages/template-retail-react-app/app/page-designer/index.js
+++ b/packages/template-retail-react-app/app/page-designer/index.js
@@ -5,6 +5,6 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
-import {Page, pageType} from '@salesforce/commerce-sdk-react/components'
+import {Page, pageType} from '@salesforce/commerce-sdk-react/page-designer'
export {Page, pageType}
diff --git a/packages/template-retail-react-app/app/page-designer/layouts/carousel/index.jsx b/packages/template-retail-react-app/app/page-designer/layouts/carousel/index.jsx
index 92b1839011..095b9ceb19 100644
--- a/packages/template-retail-react-app/app/page-designer/layouts/carousel/index.jsx
+++ b/packages/template-retail-react-app/app/page-designer/layouts/carousel/index.jsx
@@ -15,7 +15,7 @@ import {
useBreakpoint,
useBreakpointValue
} from '@salesforce/retail-react-app/app/components/shared/ui'
-import {Component, regionPropType} from '@salesforce/commerce-sdk-react/components'
+import {Component, regionPropType} from '@salesforce/commerce-sdk-react/page-designer'
import {ChevronLeftIcon, ChevronRightIcon} from '@salesforce/retail-react-app/app/components/icons'
import {useEffect} from 'react'
import {useIntl} from 'react-intl'
diff --git a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid1r1c/index.jsx b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid1r1c/index.jsx
index 1c9c460fcc..d38e368b65 100644
--- a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid1r1c/index.jsx
+++ b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid1r1c/index.jsx
@@ -7,7 +7,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import {SimpleGrid} from '@salesforce/retail-react-app/app/components/shared/ui'
-import {Region, regionPropType} from '@salesforce/commerce-sdk-react/components'
+import {Region, regionPropType} from '@salesforce/commerce-sdk-react/page-designer'
/**
* This layout component displays its children in a 1 x 1 grid on both mobile and desktop.
diff --git a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r1c/index.jsx b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r1c/index.jsx
index 7ce4337af2..986dad5f59 100644
--- a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r1c/index.jsx
+++ b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r1c/index.jsx
@@ -7,7 +7,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import {SimpleGrid} from '@salesforce/retail-react-app/app/components/shared/ui'
-import {Region, regionPropType} from '@salesforce/commerce-sdk-react/components'
+import {Region, regionPropType} from '@salesforce/commerce-sdk-react/page-designer'
/**
* This layout component displays its children in a 2 row x 1 column grid on mobile
diff --git a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r2c/index.jsx b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r2c/index.jsx
index da091eead1..5bd6a1dcdb 100644
--- a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r2c/index.jsx
+++ b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r2c/index.jsx
@@ -7,7 +7,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import {SimpleGrid} from '@salesforce/retail-react-app/app/components/shared/ui'
-import {Region, regionPropType} from '@salesforce/commerce-sdk-react/components'
+import {Region, regionPropType} from '@salesforce/commerce-sdk-react/page-designer'
/**
* This layout component displays its children in a 2 row x 2 column grid on mobile
diff --git a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r3c/index.jsx b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r3c/index.jsx
index 33dde95a1c..cc8eb2dce5 100644
--- a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r3c/index.jsx
+++ b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid2r3c/index.jsx
@@ -7,7 +7,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import {SimpleGrid} from '@salesforce/retail-react-app/app/components/shared/ui'
-import {Region, regionPropType} from '@salesforce/commerce-sdk-react/components'
+import {Region, regionPropType} from '@salesforce/commerce-sdk-react/page-designer'
/**
* This layout component displays its children in a 2 row x 3 column grid on mobile
diff --git a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid3r1c/index.jsx b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid3r1c/index.jsx
index 821fd45a65..0aa00f6a3a 100644
--- a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid3r1c/index.jsx
+++ b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid3r1c/index.jsx
@@ -7,7 +7,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import {SimpleGrid} from '@salesforce/retail-react-app/app/components/shared/ui'
-import {Region, regionPropType} from '@salesforce/commerce-sdk-react/components'
+import {Region, regionPropType} from '@salesforce/commerce-sdk-react/page-designer'
/**
* This layout component displays its children in a 3 row x 1 column grid on mobile
diff --git a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid3r2c/index.jsx b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid3r2c/index.jsx
index 09984abd5e..72cd667651 100644
--- a/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid3r2c/index.jsx
+++ b/packages/template-retail-react-app/app/page-designer/layouts/mobileGrid3r2c/index.jsx
@@ -7,7 +7,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import {SimpleGrid} from '@salesforce/retail-react-app/app/components/shared/ui'
-import {Region, regionPropType} from '@salesforce/commerce-sdk-react/components'
+import {Region, regionPropType} from '@salesforce/commerce-sdk-react/page-designer'
/**
* This layout component displays its children in a 3 row x 2 column grid on mobile
diff --git a/packages/template-retail-react-app/app/page-designer/registry.js b/packages/template-retail-react-app/app/page-designer/registry.js
new file mode 100644
index 0000000000..225fcc79c5
--- /dev/null
+++ b/packages/template-retail-react-app/app/page-designer/registry.js
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React from 'react'
+import {registry} from '@salesforce/commerce-sdk-react/page-designer'
+
+/**
+ * Initialize the component registry with all Page Designer components.
+ *
+ * This function registers lazy-loaded importers for Page Designer components,
+ * allowing them to be dynamically loaded when needed. This reduces the initial
+ * bundle size and improves performance.
+ *
+ * Component Type IDs follow the pattern: `{namespace}.{componentId}`
+ * - commerce_assets: Visual components (images, text, etc.)
+ * - commerce_layouts: Layout components (grids, carousels, etc.)
+ *
+ * @example
+ * // Call this once during app initialization
+ * initializeRegistry();
+ *
+ * // The registry will then lazy-load components as needed
+ * const Component = registry.getComponent('commerce_assets.imageTile');
+ */
+export function initializeRegistry() {
+ // Commerce Assets - Visual components
+ registry.registerImporter('commerce_assets.imageAndText', () =>
+ import('./assets/image-with-text')
+ )
+ registry.registerImporter('commerce_assets.productTile', () =>
+ import('./assets/image-with-text')
+ )
+
+ // Commerce Layouts - Layout components
+ registry.registerImporter('commerce_layouts.mobileGrid1r1c', () =>
+ import('./layouts/mobileGrid1r1c')
+ )
+ registry.registerImporter('commerce_layouts.mobileGrid2r1c', () =>
+ import('./layouts/mobileGrid2r1c')
+ )
+ registry.registerImporter('commerce_layouts.mobileGrid2r2c', () =>
+ import('./layouts/mobileGrid2r2c')
+ )
+ registry.registerImporter('commerce_layouts.mobileGrid2r3c', () =>
+ import('./layouts/mobileGrid2r3c')
+ )
+ registry.registerImporter('commerce_layouts.mobileGrid3r1c', () =>
+ import('./layouts/mobileGrid3r1c')
+ )
+ registry.registerImporter('commerce_layouts.mobileGrid3r2c', () =>
+ import('./layouts/mobileGrid3r2c')
+ )
+}
diff --git a/packages/template-retail-react-app/app/pages/product-list/partials/page-designer-promotional-banner.jsx b/packages/template-retail-react-app/app/pages/product-list/partials/page-designer-promotional-banner.jsx
index d77d39123c..f43704e5fc 100644
--- a/packages/template-retail-react-app/app/pages/product-list/partials/page-designer-promotional-banner.jsx
+++ b/packages/template-retail-react-app/app/pages/product-list/partials/page-designer-promotional-banner.jsx
@@ -5,11 +5,11 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React from 'react'
-import {usePage, useUsid, useShopperContext} from '@salesforce/commerce-sdk-react'
+import {useUsid, useShopperContext} from '@salesforce/commerce-sdk-react'
+import {usePage, Page} from '@salesforce/commerce-sdk-react/page-designer'
// Components
import {Box} from '@salesforce/retail-react-app/app/components/shared/ui'
-import {Page} from '@salesforce/commerce-sdk-react/components'
// Page Designer Components
import {ImageWithText} from '@salesforce/retail-react-app/app/page-designer/assets'
diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js
index 5be0cdc8e5..c845ac2d7e 100644
--- a/packages/template-retail-react-app/app/ssr.js
+++ b/packages/template-retail-react-app/app/ssr.js
@@ -44,10 +44,7 @@ const options = {
// The protocol on which the development Express app listens.
// Note that http://localhost is treated as a secure context for development,
// except by Safari.
- protocol: process.env.DEV_SERVER_PROTOCOL || 'http',
-
- // SSL file path for HTTPS development
- sslFilePath: process.env.DEV_SERVER_SSL_FILE_PATH,
+ protocol: 'http',
// Option for whether to set up a special endpoint for handling
// private SLAS clients
@@ -61,7 +58,7 @@ const options = {
// private client secret handler will inject an Authorization header.
// The default regex is defined in this file: https://github.com/SalesforceCommerceCloud/pwa-kit/blob/develop/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js
// applySLASPrivateClientToEndpoints:
- // /\/oauth2\/(token|passwordless\/(login|token)|password\/(reset|action))/,
+ // /\/oauth2\/(token|passwordless\/(login|token)|password\/(reset|action))/,
// If this is enabled, any HTTP header that has a non ASCII value will be URI encoded
// If there any HTTP headers that have been encoded, an additional header will be
@@ -359,19 +356,11 @@ const {handler} = runtime.createHandler(options, (app) => {
'img-src': [
// Default source for product images - replace with your CDN
'*.commercecloud.salesforce.com',
- '*.demandware.net',
- '*.adyen.com'
+ '*.demandware.net'
],
'script-src': [
// Used by the service worker in /worker/main.js
'storage.googleapis.com',
- // Payment gateways
- '*.stripe.com',
- '*.paypal.com',
- '*.adyen.com',
- 'pay.google.com',
- 'www.gstatic.com',
- '*.demandware.net', // Used to load a valid payment scripts in test environment
'maps.googleapis.com',
'places.googleapis.com'
],
@@ -384,24 +373,16 @@ const {handler} = runtime.createHandler(options, (app) => {
'places.googleapis.com',
// Connect to SCRT2 URLs
'*.salesforce-scrt.com',
- // Payment gateways
- '*.demandware.net', // Used to load a valid payment scripts in test environment
- '*.adyen.com',
- '*.paypal.com',
- 'pay.google.com',
- 'payments.google.com',
- 'google.com',
- 'www.google.com'
+ // Connect to SFCC/ODS instances
+ '*.demandware.net'
],
'frame-src': [
// Allow frames from Salesforce site.com (Needed for MIAW)
- '*.site.com',
- // Payment gateways
- '*.stripe.com',
- '*.paypal.com',
- '*.adyen.com',
- 'payments.google.com',
- 'pay.google.com'
+ '*.site.com'
+ ],
+ 'frame-ancestors': [
+ // Allow Page Designer to embed the storefront in an iframe
+ '*.demandware.net'
]
}
}
@@ -458,47 +439,6 @@ const {handler} = runtime.createHandler(options, (app) => {
app.get('/favicon.ico', runtime.serveStaticFile('static/ico/favicon.ico'))
app.get('/worker.js(.map)?', runtime.serveServiceWorker)
-
- // Helper function to transform relative icon paths to absolute URLs
- function transformIconPaths(data, ecomServerHost) {
- const baseUrl = `https://${ecomServerHost}/on/demandware.static/Sites-Site/-/-/internal`
- const methodTypes = data?.paymentMethodTypes
- if (methodTypes) {
- for (const method of Object.values(methodTypes)) {
- for (const image of method.images ?? []) {
- if (image.src?.startsWith('/icons/')) {
- image.src = `${baseUrl}${image.src}`
- }
- }
- }
- }
- return data
- }
-
- // Helper function to fetch payment metadata from the Commerce Cloud instance
- app.get('/api/payment-metadata', async (req, res) => {
- try {
- const response = await fetch(config.app.sfPayments.metadataUrl, {
- headers: {Accept: 'application/json'}
- })
- if (!response.ok) {
- throw new Error(`Metadata request failed with status: ${response.status}`)
- }
- const data = await response.json()
- const transformedData = transformIconPaths(
- data,
- new URL(config.app.sfPayments.metadataUrl).hostname
- )
- res.setHeader('Content-Type', 'application/json')
- res.json(transformedData)
- } catch (error) {
- res.status(500).json({
- error: 'Failed to fetch metadata',
- details: error.message
- })
- }
- })
-
app.get('*', runtime.render)
})
// SSR requires that we export a single handler function called 'get', that
diff --git a/packages/template-retail-react-app/app/utils/site-utils.js b/packages/template-retail-react-app/app/utils/site-utils.js
index 740046d007..2fccf329d0 100644
--- a/packages/template-retail-react-app/app/utils/site-utils.js
+++ b/packages/template-retail-react-app/app/utils/site-utils.js
@@ -231,3 +231,34 @@ function getPathnameAndSearch(url) {
const {pathname, search} = new URL(url, 'https://www.some-domain.com')
return {pathname, search}
}
+
+/**
+ * Extract Page Designer parameters from a given URL.
+ * These parameters are used when previewing pages in Page Designer edit mode.
+ * @param {string} url - The URL to extract parameters from
+ * @returns {{mode?: string, pdToken?: string, pageId?: string}} - Page Designer parameters
+ */
+export const resolvePageDesignerParamsFromUrl = (url) => {
+ if (!url) {
+ return {}
+ }
+ const {search} = getPathnameAndSearch(url)
+ const searchParams = new URLSearchParams(search)
+
+ const params = {}
+ const mode = searchParams.get('mode')
+ const pdToken = searchParams.get('pdToken')
+ const pageId = searchParams.get('pageId')
+
+ if (mode) {
+ params.mode = mode
+ }
+ if (pdToken) {
+ params.pdToken = pdToken
+ }
+ if (pageId) {
+ params.pageId = pageId
+ }
+
+ return params
+}
diff --git a/packages/template-retail-react-app/app/utils/site-utils.test.js b/packages/template-retail-react-app/app/utils/site-utils.test.js
index 169685c63c..a4dafe2350 100644
--- a/packages/template-retail-react-app/app/utils/site-utils.test.js
+++ b/packages/template-retail-react-app/app/utils/site-utils.test.js
@@ -15,7 +15,8 @@ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
import {
getParamsFromPath,
- resolveLocaleFromUrl
+ resolveLocaleFromUrl,
+ resolvePageDesignerParamsFromUrl
} from '@salesforce/retail-react-app/app/utils/site-utils'
jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => {
const origin = jest.requireActual('@salesforce/pwa-kit-react-sdk/ssr/universal/utils')
@@ -374,3 +375,57 @@ describe('resolveLocaleFromUrl', function () {
})
})
})
+
+describe('resolvePageDesignerParamsFromUrl', function () {
+ test('returns empty object when no url is provided', () => {
+ const result = resolvePageDesignerParamsFromUrl('')
+ expect(result).toEqual({})
+ })
+
+ test('returns empty object when url has no Page Designer params', () => {
+ const result = resolvePageDesignerParamsFromUrl('/category/womens')
+ expect(result).toEqual({})
+ })
+
+ test('extracts mode parameter from url', () => {
+ const result = resolvePageDesignerParamsFromUrl('/?mode=EDIT')
+ expect(result).toEqual({mode: 'EDIT'})
+ })
+
+ test('extracts pdToken parameter from url', () => {
+ const result = resolvePageDesignerParamsFromUrl('/?pdToken=abc123')
+ expect(result).toEqual({pdToken: 'abc123'})
+ })
+
+ test('extracts pageId parameter from url', () => {
+ const result = resolvePageDesignerParamsFromUrl('/?pageId=homepage')
+ expect(result).toEqual({pageId: 'homepage'})
+ })
+
+ test('extracts all Page Designer parameters from url', () => {
+ const result = resolvePageDesignerParamsFromUrl(
+ '/?mode=EDIT&pdToken=xyz789&pageId=homepage'
+ )
+ expect(result).toEqual({
+ mode: 'EDIT',
+ pdToken: 'xyz789',
+ pageId: 'homepage'
+ })
+ })
+
+ test('extracts Page Designer parameters with path and other query params', () => {
+ const result = resolvePageDesignerParamsFromUrl(
+ '/us/en-US/women?site=us&mode=PREVIEW&pdToken=token123&pageId=test-page'
+ )
+ expect(result).toEqual({
+ mode: 'PREVIEW',
+ pdToken: 'token123',
+ pageId: 'test-page'
+ })
+ })
+
+ test('ignores non-Page Designer query parameters', () => {
+ const result = resolvePageDesignerParamsFromUrl('/?mode=EDIT&foo=bar&baz=qux')
+ expect(result).toEqual({mode: 'EDIT'})
+ })
+})
diff --git a/packages/template-retail-react-app/app/utils/test-utils.js b/packages/template-retail-react-app/app/utils/test-utils.js
index 6fd8ee0e3b..4838df9ff9 100644
--- a/packages/template-retail-react-app/app/utils/test-utils.js
+++ b/packages/template-retail-react-app/app/utils/test-utils.js
@@ -16,7 +16,7 @@ import {BonusProductSelectionModalProvider} from '@salesforce/retail-react-app/a
import {ServerContext} from '@salesforce/pwa-kit-react-sdk/ssr/universal/contexts'
import {IntlProvider} from 'react-intl'
import {CommerceApiProvider} from '@salesforce/commerce-sdk-react'
-import {PageContext, Region} from '@salesforce/commerce-sdk-react/components'
+import {PageContext, Region, registry} from '@salesforce/commerce-sdk-react/page-designer'
import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/components/with-react-query'
import fallbackMessages from '@salesforce/retail-react-app/app/static/translations/compiled/en-GB.json'
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
@@ -279,11 +279,36 @@ export const withPageProvider = (Component, options) => {
}
}
const wrappedComponentName = Component.displayName || Component.name
- const WrappedComponent = (props) => (
-
-
-
- )
+ const WrappedComponent = (props) => {
+ // Register mock components in the registry for any typeIds found in regions.
+ // The new Region/Component pipeline uses registry.getComponent() instead of PageContext.
+ const MockComponent = (mockProps) => (
+
+ {mockProps.typeId}
+
+ )
+ const collectTypeIds = (regions) => {
+ const typeIds = new Set()
+ regions?.forEach((region) => {
+ region.components?.forEach((comp) => {
+ if (comp.typeId) typeIds.add(comp.typeId)
+ })
+ })
+ return typeIds
+ }
+ // eslint-disable-next-line react/prop-types
+ collectTypeIds(props.regions).forEach((typeId) => {
+ if (!registry.getComponent(typeId)) {
+ registry.registerComponent(typeId, MockComponent)
+ }
+ })
+
+ return (
+
+
+
+ )
+ }
WrappedComponent.displayName = `withRouter(${wrappedComponentName})`
return WrappedComponent
diff --git a/packages/template-retail-react-app/jest.config.js b/packages/template-retail-react-app/jest.config.js
index f2780cc6be..835eef1dae 100644
--- a/packages/template-retail-react-app/jest.config.js
+++ b/packages/template-retail-react-app/jest.config.js
@@ -20,9 +20,21 @@ module.exports = {
'^is-what$': '/node_modules/is-what/dist/cjs/index.cjs',
'^copy-anything$': '/node_modules/copy-anything/dist/cjs/index.cjs',
'^@salesforce/cc-datacloud-typescript$':
- '/node_modules/@salesforce/cc-datacloud-typescript/dist/index.js'
+ '/node_modules/@salesforce/cc-datacloud-typescript/dist/index.js',
+ '^@salesforce/storefront-next-runtime/design/react/core$':
+ '@salesforce/storefront-next-runtime/dist/design-react-core.js',
+ '^@salesforce/storefront-next-runtime/design/react$':
+ '@salesforce/storefront-next-runtime/dist/design-react.js',
+ '^@salesforce/storefront-next-runtime/design$':
+ '@salesforce/storefront-next-runtime/dist/design.js',
+ '^@salesforce/storefront-next-runtime/design/mode$':
+ '@salesforce/storefront-next-runtime/dist/design-mode.js',
+ '^@salesforce/storefront-next-runtime/scapi$':
+ '@salesforce/storefront-next-runtime/dist/scapi.js',
+ '^@salesforce/storefront-next-runtime/design/styles\\.css$':
+ '/app/mocks/empty-mock.js'
},
- transformIgnorePatterns: ['/node_modules/(?!@salesforce/cc-datacloud-typescript)'],
+ transformIgnorePatterns: ['/node_modules/(?!@salesforce/cc-datacloud-typescript|@salesforce/storefront-next-runtime)'],
setupFilesAfterEnv: [path.join(__dirname, 'jest-setup.js')],
collectCoverageFrom: [
'app/**/*.{js,jsx}',
diff --git a/packages/template-retail-react-app/package-lock.json b/packages/template-retail-react-app/package-lock.json
index 44bbcb46fb..93f9e7960f 100644
--- a/packages/template-retail-react-app/package-lock.json
+++ b/packages/template-retail-react-app/package-lock.json
@@ -20,6 +20,7 @@
"@loadable/component": "^5.15.3",
"@peculiar/webcrypto": "^1.4.2",
"@salesforce/cc-datacloud-typescript": "1.1.2",
+ "@salesforce/storefront-next-runtime": "0.1.1",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.29.1",
"@testing-library/dom": "^9.0.1",
@@ -818,7 +819,6 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-2.7.0.tgz",
"integrity": "sha512-+FcUFQMsPfhWuM9Iu7uqufwwhmHN2IX6FWsBixYGOalO86dpgETsILMZP9PuWfgj7GpWiy2Dum6HXekh0Tk2Mg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@chakra-ui/accordion": "2.2.0",
"@chakra-ui/alert": "2.1.0",
@@ -1239,7 +1239,6 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.9.1.tgz",
"integrity": "sha512-jhYKBLxwOPi9/bQt9kqV3ELa/4CjmNNruTyXlPp5M0v0+pDMUngPp48mVLoskm9RKZGE0h1qpvj/jZ3K7c7t8w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@chakra-ui/shared-utils": "2.0.5",
"csstype": "^3.0.11",
@@ -1266,7 +1265,6 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-2.5.8.tgz",
"integrity": "sha512-Vy8UUaCxikOzOGE54IP8tKouvU38rEYU1HCSquU9+oe7Jd70HaiLa4vmUKvHyMUmxkOzDHIkgZLbVQCubSnN5w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@chakra-ui/color-mode": "2.1.12",
"@chakra-ui/object-utils": "2.1.0",
@@ -1523,7 +1521,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1567,7 +1564,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2091,6 +2087,37 @@
"node": ">=18"
}
},
+ "node_modules/@salesforce/storefront-next-runtime": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@salesforce/storefront-next-runtime/-/storefront-next-runtime-0.1.1.tgz",
+ "integrity": "sha512-j8TrIMppOdO2T+IcGfaENei3hcE3yrq3fuWj2o8yfIDB555FU9Td9DFA/UuxOSnsMqQrFw2+X3GG1rsTBoZHGA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "openapi-fetch": "0.15.0"
+ },
+ "engines": {
+ "node": ">=24.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=19.0.0",
+ "react-dom": ">=19.0.0"
+ }
+ },
+ "node_modules/@salesforce/storefront-next-runtime/node_modules/openapi-fetch": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.15.0.tgz",
+ "integrity": "sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "openapi-typescript-helpers": "^0.0.15"
+ }
+ },
+ "node_modules/@salesforce/storefront-next-runtime/node_modules/openapi-typescript-helpers": {
+ "version": "0.0.15",
+ "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz",
+ "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==",
+ "license": "MIT"
+ },
"node_modules/@sentry/core": {
"version": "6.19.7",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.19.7.tgz",
@@ -2252,7 +2279,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.42.0.tgz",
"integrity": "sha512-j0tiofkzE3CSrYKmVRaKuwGgvCE+P2OOEDlhmfjeZf5ufcuFHwYwwgw3j08n4WYPVZ+OpsHblcFYezhKA3jDwg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@tanstack/query-core": "4.41.0",
"use-sync-external-store": "^1.2.0"
@@ -2300,7 +2326,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
"integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -2539,7 +2564,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -4338,7 +4362,6 @@
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.18.0.tgz",
"integrity": "sha512-oGlDh1Q1XqYPksuTD/usb0I70hq95OUzmL9+6Zd+Hs4XV0oaISBa/UUMSjYiq6m8EUF32132mOJ8xVZS+I0S6w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.4.0"
},
@@ -7573,7 +7596,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -7598,7 +7620,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json
index 0553ef24a6..cf045b4f0c 100644
--- a/packages/template-retail-react-app/package.json
+++ b/packages/template-retail-react-app/package.json
@@ -50,6 +50,7 @@
"@salesforce/pwa-kit-dev": "3.17.0-dev",
"@salesforce/pwa-kit-react-sdk": "3.17.0-dev",
"@salesforce/pwa-kit-runtime": "3.17.0-dev",
+ "@salesforce/storefront-next-runtime": "0.1.1",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.29.1",
"@testing-library/dom": "^9.0.1",
@@ -92,6 +93,10 @@
"overrides": {
"react-router": {
"path-to-regexp": "^1.9.0"
+ },
+ "@salesforce/storefront-next-runtime": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
}
},
"engines": {
@@ -105,7 +110,7 @@
},
{
"path": "build/vendor.js",
- "maxSize": "370 kB"
+ "maxSize": "391 kB"
}
]
}