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 ( + + {alt} + + ) +} +``` + +**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 ( + + {alt} + + ) +} +``` + +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 linkalt tag myaccount_addresses image

', 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 Linkalt text myaccount_registry image

', 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" } ] }