diff --git a/package-lock.json b/package-lock.json index a1fad3a7ac9..ae07a69da56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65640,6 +65640,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@playwright/test": "1.45.3", "@types/node": "20.14.12", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", @@ -65649,6 +65650,21 @@ "typescript": "5.4.5" } }, + "packages/samples/headless-ssr-commerce/node_modules/@playwright/test": { + "version": "1.45.3", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.3.tgz", + "integrity": "sha512-UKF4XsBfy+u3MFWEH44hva1Q8Da28G6RFtR2+5saw+jgAFQV5yYnB1fu68Mz7fO+5GJF3wgwAIs0UelU8TxFrA==", + "dev": true, + "dependencies": { + "playwright": "1.45.3" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "packages/samples/headless-ssr-commerce/node_modules/agent-base": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", @@ -65704,6 +65720,20 @@ "node": ">= 6" } }, + "packages/samples/headless-ssr-commerce/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "packages/samples/headless-ssr-commerce/node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -65793,6 +65823,36 @@ "dev": true, "license": "MIT" }, + "packages/samples/headless-ssr-commerce/node_modules/playwright": { + "version": "1.45.3", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.3.tgz", + "integrity": "sha512-QhVaS+lpluxCaioejDZ95l4Y4jSFCsBvl2UZkpeXlzxmqS+aABr5c82YmfMHrL6x27nvrvykJAFpkzT2eWdJww==", + "dev": true, + "dependencies": { + "playwright-core": "1.45.3" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "packages/samples/headless-ssr-commerce/node_modules/playwright-core": { + "version": "1.45.3", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.3.tgz", + "integrity": "sha512-+ym0jNbcjikaOwwSZycFbwkWgfruWvYlJfThKYAlImbxUgdWFO2oW70ojPm4OpE4t6TAo2FY/smM+hpVTtkhDA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "packages/samples/headless-ssr-commerce/node_modules/tough-cookie": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz", diff --git a/packages/headless-react/src/ssr-commerce/commerce-engine.test.tsx b/packages/headless-react/src/ssr-commerce/commerce-engine.test.tsx index e9b5c2fb56f..0d32a09560a 100644 --- a/packages/headless-react/src/ssr-commerce/commerce-engine.test.tsx +++ b/packages/headless-react/src/ssr-commerce/commerce-engine.test.tsx @@ -49,6 +49,7 @@ describe('Headless react SSR utils', () => { listingEngineDefinition, searchEngineDefinition, standaloneEngineDefinition, + recommendationEngineDefinition, ...rest } = defineCommerceEngine({configuration: sampleConfig}); const { diff --git a/packages/headless-react/src/ssr-commerce/commerce-engine.tsx b/packages/headless-react/src/ssr-commerce/commerce-engine.tsx index 16f9fed15f8..3a64598676d 100644 --- a/packages/headless-react/src/ssr-commerce/commerce-engine.tsx +++ b/packages/headless-react/src/ssr-commerce/commerce-engine.tsx @@ -46,12 +46,14 @@ export function defineCommerceEngine< >; type ListingContext = ContextStateType; type SearchContext = ContextStateType; + type RecommendationContext = ContextStateType; type StandaloneContext = ContextStateType; const { listingEngineDefinition, searchEngineDefinition, standaloneEngineDefinition, + recommendationEngineDefinition, } = defineBaseCommerceEngine({...options}); return { useEngine: buildEngineHook(singletonContext), @@ -84,5 +86,14 @@ export function defineCommerceEngine< singletonContext as StandaloneContext ), }, + recommendationEngineDefinition: { + ...recommendationEngineDefinition, + StaticStateProvider: buildStaticStateProvider( + singletonContext as RecommendationContext + ), + HydratedStateProvider: buildHydratedStateProvider( + singletonContext as RecommendationContext + ), + }, }; } diff --git a/packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts b/packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts index fe273edabcf..69e82a409f3 100644 --- a/packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts +++ b/packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts @@ -1,111 +1,22 @@ /** * Utility functions to be used for Commerce Server Side Rendering. */ -import {Action, UnknownAction} from '@reduxjs/toolkit'; -import {stateKey} from '../../app/state-key.js'; -import {buildProductListing} from '../../controllers/commerce/product-listing/headless-product-listing.js'; -import {buildSearch} from '../../controllers/commerce/search/headless-search.js'; import type {Controller} from '../../controllers/controller/headless-controller.js'; -import {createWaitForActionMiddleware} from '../../utils/utils.js'; -import {buildControllerDefinitions} from '../commerce-ssr-engine/common.js'; +import { + buildFactory, + CommerceEngineDefinitionOptions, +} from '../commerce-ssr-engine/factories/build-factory.js'; +import {hydratedStaticStateFactory} from '../commerce-ssr-engine/factories/hydrated-state-factory.js'; +import {hydratedRecommendationStaticStateFactory} from '../commerce-ssr-engine/factories/recommendation-hydrated-state-factory.js'; +import {fetchRecommendationStaticStateFactory} from '../commerce-ssr-engine/factories/recommendation-static-state-factory.js'; +import {fetchStaticStateFactory} from '../commerce-ssr-engine/factories/static-state-factory.js'; import { ControllerDefinitionsMap, - InferControllerStaticStateMapFromDefinitionsWithSolutionType, SolutionType, } from '../commerce-ssr-engine/types/common.js'; -import { - EngineDefinition, - EngineDefinitionOptions, -} from '../commerce-ssr-engine/types/core-engine.js'; -import {buildLogger} from '../logger.js'; +import {EngineDefinition} from '../commerce-ssr-engine/types/core-engine.js'; import {NavigatorContextProvider} from '../navigatorContextProvider.js'; -import {composeFunction} from '../ssr-engine/common.js'; -import {createStaticState} from '../ssr-engine/common.js'; -import { - EngineStaticState, - InferControllerPropsMapFromDefinitions, -} from '../ssr-engine/types/common.js'; -import { - CommerceEngine, - CommerceEngineOptions, - buildCommerceEngine, -} from './commerce-engine.js'; - -/** - * The SSR commerce engine. - */ -export interface SSRCommerceEngine extends CommerceEngine { - /** - * Waits for the search to be completed and returns a promise that resolves to a `SearchCompletedAction`. - */ - waitForRequestCompletedAction(): Promise; -} - -export type CommerceEngineDefinitionOptions< - TControllers extends ControllerDefinitionsMap, -> = EngineDefinitionOptions; - -function isListingFetchCompletedAction(action: unknown): action is Action { - return /^commerce\/productListing\/fetch\/(fulfilled|rejected)$/.test( - (action as UnknownAction).type - ); -} - -function isSearchCompletedAction(action: unknown): action is Action { - return /^commerce\/search\/executeSearch\/(fulfilled|rejected)$/.test( - (action as UnknownAction).type - ); -} - -function noSearchActionRequired(_action: unknown): _action is Action { - return true; -} - -function buildSSRCommerceEngine( - solutionType: SolutionType, - options: CommerceEngineOptions -): SSRCommerceEngine { - let actionCompletionMiddleware: ReturnType< - typeof createWaitForActionMiddleware - >; - - switch (solutionType) { - case SolutionType.listing: - actionCompletionMiddleware = createWaitForActionMiddleware( - isListingFetchCompletedAction - ); - break; - case SolutionType.search: - actionCompletionMiddleware = createWaitForActionMiddleware( - isSearchCompletedAction - ); - break; - default: - actionCompletionMiddleware = createWaitForActionMiddleware( - noSearchActionRequired - ); - } - - const commerceEngine = buildCommerceEngine({ - ...options, - middlewares: [ - ...(options.middlewares ?? []), - actionCompletionMiddleware.middleware, - ], - }); - - return { - ...commerceEngine, - - get [stateKey]() { - return commerceEngine[stateKey]; - }, - - waitForRequestCompletedAction() { - return actionCompletionMiddleware.promise; - }, - }; -} +import {CommerceEngineOptions} from './commerce-engine.js'; export interface CommerceEngineDefinition< TControllers extends ControllerDefinitionsMap, @@ -139,30 +50,14 @@ export function defineCommerceEngine< TControllerDefinitions, SolutionType.standalone >; -} { - const {controllers: controllerDefinitions, ...engineOptions} = options; - type Definition = CommerceEngineDefinition< + recommendationEngineDefinition: CommerceEngineDefinition< TControllerDefinitions, - SolutionType + SolutionType.recommendation >; - type BuildFunction = Definition['build']; - type FetchStaticStateFunction = Definition['fetchStaticState']; - type HydrateStaticStateFunction = Definition['hydrateStaticState']; - type FetchStaticStateFromBuildResultFunction = - FetchStaticStateFunction['fromBuildResult']; - type HydrateStaticStateFromBuildResultFunction = - HydrateStaticStateFunction['fromBuildResult']; - type BuildParameters = Parameters; - type FetchStaticStateParameters = Parameters; - type HydrateStaticStateParameters = Parameters; - type FetchStaticStateFromBuildResultParameters = - Parameters; - type HydrateStaticStateFromBuildResultParameters = - Parameters; +} { + const {controllers: controllerDefinitions, ...engineOptions} = options; - const getOptions = () => { - return engineOptions; - }; + const getOptions = () => engineOptions; const setNavigatorContextProvider = ( navigatorContextProvider: NavigatorContextProvider @@ -170,130 +65,56 @@ export function defineCommerceEngine< engineOptions.navigatorContextProvider = navigatorContextProvider; }; - const buildFactory = - (solutionType: T) => - async (...[buildOptions]: BuildParameters) => { - const logger = buildLogger(options.loggerOptions); - if (!getOptions().navigatorContextProvider) { - logger.warn( - '[WARNING] Missing navigator context in server-side code. Make sure to set it with `setNavigatorContextProvider` before calling fetchStaticState()' - ); - } - const engine = buildSSRCommerceEngine( - solutionType, - buildOptions?.extend - ? await buildOptions.extend(getOptions()) - : getOptions() - ); - const controllers = buildControllerDefinitions({ - definitionsMap: (controllerDefinitions ?? {}) as TControllerDefinitions, - engine, - solutionType, - propsMap: (buildOptions && 'controllers' in buildOptions - ? buildOptions.controllers - : {}) as InferControllerPropsMapFromDefinitions, - }); - - return { - engine, - controllers, - }; - }; - - const fetchStaticStateFactory: ( - solutionType: SolutionType - ) => FetchStaticStateFunction = (solutionType: SolutionType) => - composeFunction( - async (...params: FetchStaticStateParameters) => { - const buildResult = await buildFactory(solutionType)(...params); - const staticState = await fetchStaticStateFactory( - solutionType - ).fromBuildResult({ - buildResult, - }); - return staticState; - }, - { - fromBuildResult: async ( - ...params: FetchStaticStateFromBuildResultParameters - ) => { - const [ - { - buildResult: {engine, controllers}, - }, - ] = params; - - if (solutionType === SolutionType.listing) { - buildProductListing(engine).executeFirstRequest(); - } else if (solutionType === SolutionType.search) { - buildSearch(engine).executeFirstSearch(); - } - - const searchAction = await engine.waitForRequestCompletedAction(); - - return createStaticState({ - searchAction, - controllers, - }) as EngineStaticState< - UnknownAction, - InferControllerStaticStateMapFromDefinitionsWithSolutionType< - TControllerDefinitions, - SolutionType - > - >; - }, - } + const build = buildFactory( + controllerDefinitions, + getOptions() + ); + const fetchStaticState = fetchStaticStateFactory( + controllerDefinitions, + getOptions() + ); + const hydrateStaticState = hydratedStaticStateFactory( + controllerDefinitions, + getOptions() + ); + const fetchRecommendationStaticState = + fetchRecommendationStaticStateFactory( + controllerDefinitions, + getOptions() ); - - const hydrateStaticStateFactory: ( - solutionType: SolutionType - ) => HydrateStaticStateFunction = (solutionType: SolutionType) => - composeFunction( - async (...params: HydrateStaticStateParameters) => { - const buildResult = await buildFactory(solutionType)( - ...(params as BuildParameters) - ); - const staticState = await hydrateStaticStateFactory( - solutionType - ).fromBuildResult({ - buildResult, - searchAction: params[0]!.searchAction, - }); - return staticState; - }, - { - fromBuildResult: async ( - ...params: HydrateStaticStateFromBuildResultParameters - ) => { - const [ - { - buildResult: {engine, controllers}, - searchAction, - }, - ] = params; - engine.dispatch(searchAction); - await engine.waitForRequestCompletedAction(); - return {engine, controllers}; - }, - } + const hydrateRecommendationStaticState = + hydratedRecommendationStaticStateFactory( + controllerDefinitions, + getOptions() ); + return { listingEngineDefinition: { - build: buildFactory(SolutionType.listing), - fetchStaticState: fetchStaticStateFactory(SolutionType.listing), - hydrateStaticState: hydrateStaticStateFactory(SolutionType.listing), + build: build(SolutionType.listing), + fetchStaticState: fetchStaticState(SolutionType.listing), + hydrateStaticState: hydrateStaticState(SolutionType.listing), setNavigatorContextProvider, } as CommerceEngineDefinition, searchEngineDefinition: { - build: buildFactory(SolutionType.search), - fetchStaticState: fetchStaticStateFactory(SolutionType.search), - hydrateStaticState: hydrateStaticStateFactory(SolutionType.search), + build: build(SolutionType.search), + fetchStaticState: fetchStaticState(SolutionType.search), + hydrateStaticState: hydrateStaticState(SolutionType.search), setNavigatorContextProvider, } as CommerceEngineDefinition, + recommendationEngineDefinition: { + build: build(SolutionType.recommendation), + fetchStaticState: fetchRecommendationStaticState, + hydrateStaticState: hydrateRecommendationStaticState, + setNavigatorContextProvider, + } as CommerceEngineDefinition< + TControllerDefinitions, + SolutionType.recommendation + >, + // TODO KIT-3738 : The standaloneEngineDefinition should not be async since no request is sent to the API standaloneEngineDefinition: { - build: buildFactory(SolutionType.standalone), - fetchStaticState: fetchStaticStateFactory(SolutionType.standalone), - hydrateStaticState: hydrateStaticStateFactory(SolutionType.standalone), + build: build(SolutionType.standalone), + fetchStaticState: fetchStaticState(SolutionType.standalone), + hydrateStaticState: hydrateStaticState(SolutionType.standalone), setNavigatorContextProvider, } as CommerceEngineDefinition< TControllerDefinitions, diff --git a/packages/headless/src/app/commerce-ssr-engine/common.ts b/packages/headless/src/app/commerce-ssr-engine/common.ts index 6369a040361..081f964650b 100644 --- a/packages/headless/src/app/commerce-ssr-engine/common.ts +++ b/packages/headless/src/app/commerce-ssr-engine/common.ts @@ -1,18 +1,42 @@ +import {UnknownAction} from '@reduxjs/toolkit'; import {Controller} from '../../controllers/controller/headless-controller.js'; import {InvalidControllerDefinition} from '../../utils/errors.js'; -import {filterObject, mapObject} from '../../utils/utils.js'; -import {SSRCommerceEngine} from '../commerce-engine/commerce-engine.ssr.js'; -import {InferControllerPropsMapFromDefinitions} from '../ssr-engine/types/common.js'; +import {clone, filterObject, mapObject} from '../../utils/utils.js'; +import { + ControllersMap, + InferControllerStaticStateMapFromControllers, +} from '../ssr-engine/types/common.js'; +import {SSRCommerceEngine} from './factories/build-factory.js'; import { ControllerDefinition, ControllerDefinitionOption, ControllerDefinitionsMap, + EngineStaticState, InferControllerFromDefinition, InferControllerPropsFromDefinition, + InferControllerPropsMapFromDefinitions, InferControllersMapFromDefinition, SolutionType, } from './types/common.js'; +export function createStaticState({ + searchActions, + controllers, +}: { + searchActions: TSearchAction[]; + controllers: ControllersMap; +}): EngineStaticState< + TSearchAction, + InferControllerStaticStateMapFromControllers +> { + return { + controllers: mapObject(controllers, (controller) => ({ + state: clone(controller.state), + })) as InferControllerStaticStateMapFromControllers, + searchActions: searchActions.map((action) => clone(action)), + }; +} + function buildControllerFromDefinition< TControllerDefinition extends ControllerDefinition, >({ @@ -51,26 +75,12 @@ export function buildControllerDefinitions< TSolutionType > { const controllerMap = mapObject(definitionsMap, (definition, key) => { - const unavailableInSearchSolutionType = - 'search' in definition && - definition['search'] === false && - solutionType === SolutionType['search']; - - const unavailableInListingSolutionType = - 'listing' in definition && - definition['listing'] === false && - solutionType === SolutionType['listing']; - - const unavailableInStandaloneSolutionType = - solutionType === SolutionType['standalone'] && 'standalone' in definition - ? definition['standalone'] === false - : false; + const unavailableInSolutionType = () => + !(solutionType in definition) || + (solutionType in definition && + definition[solutionType as keyof typeof definition] === false); - if ( - unavailableInSearchSolutionType || - unavailableInListingSolutionType || - unavailableInStandaloneSolutionType - ) { + if (unavailableInSolutionType()) { return null; } diff --git a/packages/headless/src/app/commerce-ssr-engine/factories/build-factory.ts b/packages/headless/src/app/commerce-ssr-engine/factories/build-factory.ts new file mode 100644 index 00000000000..6c3e5c09772 --- /dev/null +++ b/packages/headless/src/app/commerce-ssr-engine/factories/build-factory.ts @@ -0,0 +1,165 @@ +import {Action, UnknownAction} from '@reduxjs/toolkit'; +import {stateKey} from '../../../app/state-key.js'; +import {Controller} from '../../../controllers/controller/headless-controller.js'; +import { + createWaitForActionMiddleware, + createWaitForActionMiddlewareForRecommendation, +} from '../../../utils/utils.js'; +import { + buildCommerceEngine, + CommerceEngine, + CommerceEngineOptions, +} from '../../commerce-engine/commerce-engine.js'; +import {buildLogger} from '../../logger.js'; +import {buildControllerDefinitions} from '../common.js'; +import { + ControllerDefinitionsMap, + InferControllerPropsMapFromDefinitions, + SolutionType, +} from '../types/common.js'; +import { + BuildParameters, + CommerceControllerDefinitionsMap, + EngineDefinitionOptions, +} from '../types/core-engine.js'; + +/** + * The SSR commerce engine. + */ +export interface SSRCommerceEngine extends CommerceEngine { + /** + * Waits for the request to be completed and returns a promise that resolves to an `Action`. + */ + waitForRequestCompletedAction(): Promise[]; +} + +export type CommerceEngineDefinitionOptions< + TControllers extends ControllerDefinitionsMap, +> = EngineDefinitionOptions; + +function isListingFetchCompletedAction(action: unknown): action is Action { + return /^commerce\/productListing\/fetch\/(fulfilled|rejected)$/.test( + (action as UnknownAction).type + ); +} + +function isSearchCompletedAction(action: unknown): action is Action { + return /^commerce\/search\/executeSearch\/(fulfilled|rejected)$/.test( + (action as UnknownAction).type + ); +} + +function isRecommendationCompletedAction(action: unknown): action is Action { + return /^commerce\/recommendations\/fetch\/(fulfilled|rejected)$/.test( + (action as UnknownAction).type + ); +} + +function noSearchActionRequired(_action: unknown): _action is Action { + return true; +} + +function buildSSRCommerceEngine( + solutionType: SolutionType, + options: CommerceEngineOptions, + recommendationCount: number +): SSRCommerceEngine { + let actionCompletionMiddleware: ReturnType< + typeof createWaitForActionMiddleware + >; + + const middlewares: ReturnType[] = []; + const memo: Set = new Set(); + + switch (solutionType) { + case SolutionType.listing: + actionCompletionMiddleware = createWaitForActionMiddleware( + isListingFetchCompletedAction + ); + middlewares.push(actionCompletionMiddleware); + break; + case SolutionType.search: + actionCompletionMiddleware = createWaitForActionMiddleware( + isSearchCompletedAction + ); + middlewares.push(actionCompletionMiddleware); + break; + case SolutionType.recommendation: + middlewares.push( + ...Array.from({length: recommendationCount}, () => + createWaitForActionMiddlewareForRecommendation( + isRecommendationCompletedAction, + memo + ) + ) + ); + break; + case SolutionType.standalone: + actionCompletionMiddleware = createWaitForActionMiddleware( + noSearchActionRequired + ); + break; + default: + throw new Error('Unsupported solution type', solutionType); + } + + const commerceEngine = buildCommerceEngine({ + ...options, + middlewares: [ + ...(options.middlewares ?? []), + ...middlewares.map(({middleware}) => middleware), + ], + }); + + return { + ...commerceEngine, + + get [stateKey]() { + return commerceEngine[stateKey]; + }, + + waitForRequestCompletedAction() { + return [...middlewares.map(({promise}) => promise)]; + }, + }; +} + +export const buildFactory = + ( + controllerDefinitions: TControllerDefinitions | undefined, + options: CommerceEngineDefinitionOptions + ) => + (solutionType: T) => + async (...[buildOptions]: BuildParameters) => { + const logger = buildLogger(options.loggerOptions); + if (!options.navigatorContextProvider) { + logger.warn( + '[WARNING] Missing navigator context in server-side code. Make sure to set it with `setNavigatorContextProvider` before calling fetchStaticState()' + ); + } + + const engine = buildSSRCommerceEngine( + solutionType, + buildOptions && 'extend' in buildOptions && buildOptions?.extend + ? await buildOptions.extend(options) + : options, + solutionType === SolutionType.recommendation && + Array.isArray(buildOptions) + ? buildOptions.length + : 0 + ); + + const controllers = buildControllerDefinitions({ + definitionsMap: (controllerDefinitions ?? {}) as TControllerDefinitions, + engine, + solutionType, + propsMap: (buildOptions && 'controllers' in buildOptions + ? buildOptions.controllers + : {}) as InferControllerPropsMapFromDefinitions, + }); + + return { + engine, + controllers, + }; + }; diff --git a/packages/headless/src/app/commerce-ssr-engine/factories/hydrated-state-factory.ts b/packages/headless/src/app/commerce-ssr-engine/factories/hydrated-state-factory.ts new file mode 100644 index 00000000000..b004b174ea6 --- /dev/null +++ b/packages/headless/src/app/commerce-ssr-engine/factories/hydrated-state-factory.ts @@ -0,0 +1,67 @@ +import {composeFunction} from '../../ssr-engine/common.js'; +import {SolutionType} from '../types/common.js'; +import { + BuildParameters, + HydrateStaticStateFromBuildResultParameters, + HydrateStaticStateFunction, + HydrateStaticStateParameters, + CommerceControllerDefinitionsMap, +} from '../types/core-engine.js'; +import { + buildFactory, + CommerceEngineDefinitionOptions, +} from './build-factory.js'; + +export const hydratedStaticStateFactory: < + TControllerDefinitions extends CommerceControllerDefinitionsMap, +>( + controllerDefinitions: TControllerDefinitions | undefined, + options: CommerceEngineDefinitionOptions +) => ( + solutionType: SolutionType +) => HydrateStaticStateFunction = + ( + controllerDefinitions: TControllerDefinitions | undefined, + options: CommerceEngineDefinitionOptions + ) => + (solutionType: SolutionType) => + composeFunction( + async ( + ...params: HydrateStaticStateParameters + ) => { + const solutionTypeBuild = await buildFactory(controllerDefinitions, { + ...options, + })(solutionType); + const buildResult = await solutionTypeBuild( + ...(params as BuildParameters) + ); + const staticStateBuild = + await hydratedStaticStateFactory( + controllerDefinitions, + options + )(solutionType); + const staticState = await staticStateBuild.fromBuildResult({ + buildResult, + searchActions: params[0]!.searchActions, + }); + return staticState; + }, + { + fromBuildResult: async ( + ...params: HydrateStaticStateFromBuildResultParameters + ) => { + const [ + { + buildResult: {engine, controllers}, + searchActions, + }, + ] = params; + + searchActions.forEach((action) => { + engine.dispatch(action); + }); + await engine.waitForRequestCompletedAction(); + return {engine, controllers}; + }, + } + ); diff --git a/packages/headless/src/app/commerce-ssr-engine/factories/recommendation-hydrated-state-factory.ts b/packages/headless/src/app/commerce-ssr-engine/factories/recommendation-hydrated-state-factory.ts new file mode 100644 index 00000000000..321880e1432 --- /dev/null +++ b/packages/headless/src/app/commerce-ssr-engine/factories/recommendation-hydrated-state-factory.ts @@ -0,0 +1,61 @@ +import {composeFunction} from '../../ssr-engine/common.js'; +import {SolutionType} from '../types/common.js'; +import { + BuildParameters, + BuildResult, + HydrateStaticStateFromBuildResultParameters, + HydrateStaticStateFunction, + HydrateStaticStateParameters, + CommerceControllerDefinitionsMap, +} from '../types/core-engine.js'; +import { + buildFactory, + CommerceEngineDefinitionOptions, +} from './build-factory.js'; + +export function hydratedRecommendationStaticStateFactory< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +>( + controllerDefinitions: TControllerDefinitions | undefined, + options: CommerceEngineDefinitionOptions +): HydrateStaticStateFunction { + return composeFunction( + async (...params: HydrateStaticStateParameters) => { + const solutionTypeBuild = await buildFactory( + controllerDefinitions, + options + )(SolutionType.recommendation); + + const buildResult = (await solutionTypeBuild( + ...(params as BuildParameters) + )) as BuildResult; + + const staticState = await hydratedRecommendationStaticStateFactory( + controllerDefinitions, + options + ).fromBuildResult({ + buildResult, + searchActions: params[0]!.searchActions, + }); + return staticState; + }, + { + fromBuildResult: async ( + ...params: HydrateStaticStateFromBuildResultParameters + ) => { + const [ + { + buildResult: {engine, controllers}, + searchActions, + }, + ] = params; + + searchActions.forEach((action) => { + engine.dispatch(action); + }); + await engine.waitForRequestCompletedAction(); + return {engine, controllers}; + }, + } + ); +} diff --git a/packages/headless/src/app/commerce-ssr-engine/factories/recommendation-static-state-factory.ts b/packages/headless/src/app/commerce-ssr-engine/factories/recommendation-static-state-factory.ts new file mode 100644 index 00000000000..643c937b7b6 --- /dev/null +++ b/packages/headless/src/app/commerce-ssr-engine/factories/recommendation-static-state-factory.ts @@ -0,0 +1,179 @@ +import {UnknownAction} from '@reduxjs/toolkit'; +import {Logger} from 'pino'; +import {Recommendations} from '../../../controllers/commerce/recommendations/headless-recommendations.js'; +import {RecommendationsDefinitionMeta} from '../../../controllers/commerce/recommendations/headless-recommendations.ssr.js'; +import {Controller} from '../../../controllers/controller/headless-controller.js'; +import {buildLogger} from '../../logger.js'; +import {composeFunction} from '../../ssr-engine/common.js'; +import {createStaticState} from '../common.js'; +import { + ControllerDefinition, + ControllerDefinitionsMap, + EngineStaticState, + InferControllerStaticStateMapFromDefinitionsWithSolutionType, + SolutionType, +} from '../types/common.js'; +import { + BuildResult, + Controllers, + FetchStaticStateFromBuildResultParameters, + FetchStaticStateFunction, + CommerceControllerDefinitionsMap, +} from '../types/core-engine.js'; +import { + buildFactory, + CommerceEngineDefinitionOptions, +} from './build-factory.js'; + +export function fetchRecommendationStaticStateFactory< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +>( + controllerDefinitions: TControllerDefinitions | undefined, + options: CommerceEngineDefinitionOptions +): FetchStaticStateFunction { + type ControllerDefinitionKeys = keyof Controllers; + + const logger = buildLogger(options.loggerOptions); + + return composeFunction( + async (...params: [controllerKeys: Array]) => { + const [controllerKeys] = params; + const uniqueControllerKeys = Array.from(new Set(controllerKeys)); + if (uniqueControllerKeys.length !== controllerKeys.length) { + logger.warn( + '[WARNING] Duplicate controller keys detected in recommendation fetchStaticState call. Make sure to provide only unique controller keys.' + ); + } + + const validControllerNames = Object.keys(controllerDefinitions ?? {}); + const allowedRecommendationKeys = uniqueControllerKeys.filter( + (key: string) => validControllerNames.includes(key) + ); + + if (!options.navigatorContextProvider) { + logger.warn( + '[WARNING] Missing navigator context in server-side code. Make sure to set it with `setNavigatorContextProvider` before calling fetchStaticState()' + ); + } + + const solutionTypeBuild = await buildFactory( + controllerDefinitions, + options + )(SolutionType.recommendation); + + const buildResult = (await solutionTypeBuild( + allowedRecommendationKeys + )) as BuildResult; + + const staticState = await fetchRecommendationStaticStateFactory( + controllerDefinitions, + options + ).fromBuildResult({ + buildResult, + allowedRecommendationKeys, + }); + return staticState; + }, + { + fromBuildResult: async ( + ...params: FetchStaticStateFromBuildResultParameters + ) => { + const [ + { + buildResult: {engine, controllers}, + allowedRecommendationKeys, + }, + ] = params; + + filterRecommendationControllers( + controllers, + controllerDefinitions ?? {}, + logger + ).refresh(allowedRecommendationKeys); + + const searchActions = await Promise.all( + engine.waitForRequestCompletedAction() + ); + + return createStaticState({ + searchActions, + controllers, + }) as EngineStaticState< + UnknownAction, + InferControllerStaticStateMapFromDefinitionsWithSolutionType< + TControllerDefinitions, + SolutionType + > + >; + }, + } + ); +} + +function filterRecommendationControllers< + TControllerDefinitions extends ControllerDefinitionsMap, +>( + controllers: Record, + controllerDefinitions: TControllerDefinitions, + logger: Logger +) { + const slotIdSet = new Set(); + + const isRecommendationDefinition = < + C extends ControllerDefinition, + >( + controllerDefinition: C + ): controllerDefinition is C & RecommendationsDefinitionMeta => { + return ( + 'recommendation' in controllerDefinition && + controllerDefinition.recommendation === true + ); + }; + + const warnDuplicateRecommendation = (slotId: string, productId?: string) => { + logger.warn( + 'Multiple recommendation controllers found for the same slotId and productId', + {slotId, productId} + ); + }; + + const filtered = Object.entries(controllerDefinitions).filter( + ([_, value]) => { + if (!isRecommendationDefinition(value)) { + return false; + } + const {slotId, productId} = value.options; + const key = `${slotId}${productId || ''}`; + if (slotIdSet.has(key)) { + warnDuplicateRecommendation(slotId, productId); + return false; + } + slotIdSet.add(key); + return true; + } + ); + + const name = filtered.map(([name, _]) => name); + + return { + /** + * Go through all the controllers passed in argument and only refresh recommendation controllers. + * + * @param controllers - A record of all controllers where the key is the controller name and the value is the controller instance. + * @param controllerNames - A list of all recommendation controllers to refresh + */ + refresh(whitelist?: string[]) { + if (whitelist === undefined) { + return; + } + const isRecommendationController = (key: string) => + name.includes(key) && whitelist.includes(key); + + for (const [key, controller] of Object.entries(controllers)) { + if (isRecommendationController(key)) { + (controller as Recommendations).refresh?.(); + } + } + }, + }; +} diff --git a/packages/headless/src/app/commerce-ssr-engine/factories/static-state-factory.ts b/packages/headless/src/app/commerce-ssr-engine/factories/static-state-factory.ts new file mode 100644 index 00000000000..fa79338be79 --- /dev/null +++ b/packages/headless/src/app/commerce-ssr-engine/factories/static-state-factory.ts @@ -0,0 +1,85 @@ +import {UnknownAction} from '@reduxjs/toolkit'; +import {buildProductListing} from '../../../controllers/commerce/product-listing/headless-product-listing.js'; +import {buildSearch} from '../../../controllers/commerce/search/headless-search.js'; +import {composeFunction} from '../../ssr-engine/common.js'; +import {createStaticState} from '../common.js'; +import { + EngineStaticState, + InferControllerStaticStateMapFromDefinitionsWithSolutionType, + SolutionType, +} from '../types/common.js'; +import { + FetchStaticStateFromBuildResultParameters, + FetchStaticStateFunction, + FetchStaticStateParameters, + CommerceControllerDefinitionsMap, +} from '../types/core-engine.js'; +import { + buildFactory, + CommerceEngineDefinitionOptions, +} from './build-factory.js'; + +export const fetchStaticStateFactory: < + TControllerDefinitions extends CommerceControllerDefinitionsMap, +>( + controllerDefinitions: TControllerDefinitions | undefined, + options: CommerceEngineDefinitionOptions +) => ( + solutionType: SolutionType +) => FetchStaticStateFunction = + ( + controllerDefinitions: TControllerDefinitions | undefined, + options: CommerceEngineDefinitionOptions + ) => + (solutionType: SolutionType) => + composeFunction( + async (...params: FetchStaticStateParameters) => { + const solutionTypeBuild = await buildFactory(controllerDefinitions, { + ...options, + })(solutionType); + const buildResult = await solutionTypeBuild(...params); + const staticStateBuild = await fetchStaticStateFactory( + controllerDefinitions, + options + )(solutionType); + const staticState = await staticStateBuild.fromBuildResult({ + buildResult, + }); + return staticState; + }, + { + fromBuildResult: async ( + ...params: FetchStaticStateFromBuildResultParameters + ) => { + const [ + { + buildResult: {engine, controllers}, + }, + ] = params; + + switch (solutionType) { + case SolutionType.listing: + buildProductListing(engine).executeFirstRequest(); + break; + case SolutionType.search: + buildSearch(engine).executeFirstSearch(); + break; + } + + const searchActions = await Promise.all( + engine.waitForRequestCompletedAction() + ); + + return createStaticState({ + searchActions, + controllers, + }) as EngineStaticState< + UnknownAction, + InferControllerStaticStateMapFromDefinitionsWithSolutionType< + TControllerDefinitions, + SolutionType + > + >; + }, + } + ); diff --git a/packages/headless/src/app/commerce-ssr-engine/types/build.ts b/packages/headless/src/app/commerce-ssr-engine/types/build.ts new file mode 100644 index 00000000000..ddbe2a29265 --- /dev/null +++ b/packages/headless/src/app/commerce-ssr-engine/types/build.ts @@ -0,0 +1,46 @@ +import type { + ControllersMap, + ControllersPropsMap, + EngineDefinitionBuildResult, + OptionsExtender, + OptionsTuple, +} from '../../ssr-engine/types/common.js'; +import {SSRCommerceEngine} from '../factories/build-factory.js'; +import { + EngineDefinitionControllersPropsOption, + SolutionType, +} from './common.js'; + +export interface BuildOptions { + extend?: OptionsExtender; +} + +export type Build< + TEngineOptions, + TControllersMap extends ControllersMap, + TControllersProps extends ControllersPropsMap, + TSolutionType extends SolutionType, +> = TSolutionType extends SolutionType.recommendation + ? { + /** + * Initializes an engine and controllers from the definition. + */ + ( + controllers: (keyof TControllersMap)[] + ): Promise< + EngineDefinitionBuildResult + >; + } + : { + /** + * Initializes an engine and controllers from the definition. + */ + ( + ...params: OptionsTuple< + BuildOptions & + EngineDefinitionControllersPropsOption + > + ): Promise< + EngineDefinitionBuildResult + >; + }; diff --git a/packages/headless/src/app/commerce-ssr-engine/types/common.ts b/packages/headless/src/app/commerce-ssr-engine/types/common.ts index fa205d4b3e2..918cba55ade 100644 --- a/packages/headless/src/app/commerce-ssr-engine/types/common.ts +++ b/packages/headless/src/app/commerce-ssr-engine/types/common.ts @@ -1,14 +1,24 @@ +import {UnknownAction} from '@reduxjs/toolkit'; import type {Controller} from '../../../controllers/controller/headless-controller.js'; import type {InvalidControllerDefinition} from '../../../utils/errors.js'; -import {SSRCommerceEngine} from '../../commerce-engine/commerce-engine.ssr.js'; import type { HasKey, InferControllerStaticStateMapFromControllers, InferControllerStaticStateFromController, InferControllerPropsMapFromDefinitions, + ControllerStaticStateMap, + EngineDefinitionBuildResult, + EngineDefinitionControllersPropsOption, + HydratedState, + OptionsTuple, } from '../../ssr-engine/types/common.js'; +import {SSRCommerceEngine} from '../factories/build-factory.js'; export type { + EngineDefinitionBuildResult, + EngineDefinitionControllersPropsOption, + HydratedState, + OptionsTuple, InferControllerStaticStateFromController, InferControllerStaticStateMapFromControllers, InferControllerPropsMapFromDefinitions, @@ -18,6 +28,7 @@ export enum SolutionType { search = 'search', listing = 'listing', standalone = 'standalone', + recommendation = 'recommendation', } export interface ControllerDefinitionWithoutProps< @@ -52,6 +63,14 @@ export interface ControllerDefinitionWithProps< ): TController; } +export interface EngineStaticState< + TSearchAction extends UnknownAction, + TControllers extends ControllerStaticStateMap, +> { + searchActions: TSearchAction[]; + controllers: TControllers; +} + export type ControllerDefinition = | ControllerDefinitionWithoutProps | ControllerDefinitionWithProps; @@ -144,6 +163,13 @@ interface ListingOnlyController { [SolutionType.listing]: true; } +interface RecommendationOnlyController { + /** + * @internal + */ + [SolutionType.recommendation]: true; +} + interface SearchAndListingController { /** * @internal @@ -173,6 +199,17 @@ export type ListingOnlyControllerDefinitionWithProps< TProps, > = ControllerDefinitionWithProps & ListingOnlyController; +export type RecommendationOnlyControllerDefinitionWithoutProps< + TController extends Controller, +> = ControllerDefinitionWithoutProps & + RecommendationOnlyController; + +export type RecommendationOnlyControllerDefinitionWithProps< + TController extends Controller, + TProps, +> = ControllerDefinitionWithProps & + RecommendationOnlyController; + export type UniversalControllerDefinitionWithoutProps< TController extends Controller, > = ControllerDefinitionWithoutProps & UniversalController; diff --git a/packages/headless/src/app/commerce-ssr-engine/types/core-engine.ts b/packages/headless/src/app/commerce-ssr-engine/types/core-engine.ts index a7b549f177c..2d7083e8e4a 100644 --- a/packages/headless/src/app/commerce-ssr-engine/types/core-engine.ts +++ b/packages/headless/src/app/commerce-ssr-engine/types/core-engine.ts @@ -1,20 +1,38 @@ import {UnknownAction} from '@reduxjs/toolkit'; import type {Controller} from '../../../controllers/controller/headless-controller.js'; -import {SSRCommerceEngine} from '../../commerce-engine/commerce-engine.ssr.js'; +import {CommerceEngineDefinition} from '../../commerce-engine/commerce-engine.ssr.js'; import {EngineConfiguration} from '../../engine-configuration.js'; import {NavigatorContextProvider} from '../../navigatorContextProvider.js'; -import {Build} from '../../ssr-engine/types/build.js'; -import {InferControllerPropsMapFromDefinitions} from '../../ssr-engine/types/common.js'; -import {FetchStaticState} from '../../ssr-engine/types/fetch-static-state.js'; -import {HydrateStaticState} from '../../ssr-engine/types/hydrate-static-state.js'; +import type {FromBuildResultOptions} from '../../ssr-engine/types/from-build-result.js'; +import {SSRCommerceEngine} from '../factories/build-factory.js'; +import {Build, BuildOptions} from './build.js'; import { ControllerDefinitionsMap, InferControllersMapFromDefinition, SolutionType, InferControllerStaticStateMapFromDefinitionsWithSolutionType, + InferControllerPropsMapFromDefinitions, } from './common.js'; +import { + FetchStaticState, + FetchStaticStateOptions, +} from './fetch-static-state.js'; +import {FromBuildResult} from './from-build-result.js'; +import { + HydrateStaticState, + HydrateStaticStateOptions, +} from './hydrate-static-state.js'; -export type {HydrateStaticState, FetchStaticState}; +export type { + FromBuildResult, + FromBuildResultOptions, + HydrateStaticState, + HydrateStaticStateOptions, + FetchStaticState, + FetchStaticStateOptions, + Build, + BuildOptions, +}; export type EngineDefinitionOptions< TOptions extends {configuration: EngineConfiguration}, TControllers extends ControllerDefinitionsMap, @@ -34,32 +52,32 @@ export interface EngineDefinition< * Fetches the static state on the server side using your engine definition. */ fetchStaticState: FetchStaticState< - SSRCommerceEngine, InferControllersMapFromDefinition, UnknownAction, InferControllerStaticStateMapFromDefinitionsWithSolutionType< TControllers, TSolutionType >, - InferControllerPropsMapFromDefinitions + InferControllerPropsMapFromDefinitions, + TSolutionType >; /** * Fetches the hydrated state on the client side using your engine definition and the static state. */ hydrateStaticState: HydrateStaticState< - SSRCommerceEngine, InferControllersMapFromDefinition, UnknownAction, - InferControllerPropsMapFromDefinitions + InferControllerPropsMapFromDefinitions, + TSolutionType >; /** * Builds an engine and its controllers from an engine definition. */ build: Build< - SSRCommerceEngine, TEngineOptions, InferControllersMapFromDefinition, - InferControllerPropsMapFromDefinitions + InferControllerPropsMapFromDefinitions, + TSolutionType >; /** @@ -90,3 +108,63 @@ export type InferBuildResult< build(...args: unknown[]): Promise; }, > = Awaited>; + +export type CommerceControllerDefinitionsMap = + ControllerDefinitionsMap; + +type Definition< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = CommerceEngineDefinition; + +export type BuildFunction< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = Definition['build']; + +export type FetchStaticStateFunction< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = Definition['fetchStaticState']; + +export type HydrateStaticStateFunction< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = Definition['hydrateStaticState']; + +export type FetchStaticStateFromBuildResultFunction< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = FetchStaticStateFunction['fromBuildResult']; + +export type HydrateStaticStateFromBuildResultFunction< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = HydrateStaticStateFunction['fromBuildResult']; + +export type BuildParameters< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = Parameters>; + +export type FetchStaticStateParameters< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = Parameters>; + +export type HydrateStaticStateParameters< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = Parameters>; + +export type FetchStaticStateFromBuildResultParameters< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = Parameters>; + +export type HydrateStaticStateFromBuildResultParameters< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = Parameters< + HydrateStaticStateFromBuildResultFunction +>; + +export type Controllers< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = InferControllersMapFromDefinition; + +export type BuildResult< + TControllerDefinitions extends CommerceControllerDefinitionsMap, +> = { + engine: SSRCommerceEngine; + controllers: Controllers; +}; diff --git a/packages/headless/src/app/commerce-ssr-engine/types/fetch-static-state.ts b/packages/headless/src/app/commerce-ssr-engine/types/fetch-static-state.ts new file mode 100644 index 00000000000..ec73f32afd9 --- /dev/null +++ b/packages/headless/src/app/commerce-ssr-engine/types/fetch-static-state.ts @@ -0,0 +1,58 @@ +import type {UnknownAction} from '@reduxjs/toolkit'; +import {SolutionType} from '../../commerce-ssr-engine/types/common.js'; +import type { + EngineDefinitionControllersPropsOption, + EngineStaticState, +} from '../../commerce-ssr-engine/types/common.js'; +import type { + ControllersMap, + ControllersPropsMap, + ControllerStaticStateMap, + OptionsTuple, +} from '../../ssr-engine/types/common.js'; +import {FromBuildResult} from './from-build-result.js'; + +export type FetchStaticStateOptions = {}; + +export type FetchStaticState< + TControllers extends ControllersMap, + TSearchAction extends UnknownAction, + TControllersStaticState extends ControllerStaticStateMap, + TControllersProps extends ControllersPropsMap, + TSolutionType extends SolutionType, +> = TSolutionType extends SolutionType.recommendation + ? { + /** + * Executes only the initial search for a given configuration, then returns a resumable snapshot of engine state along with the state of the controllers. + * + * Useful for static generation and server-side rendering. + */ + ( + controllers: Array + ): Promise>; + + fromBuildResult: FromBuildResult< + TControllers, + FetchStaticStateOptions, + EngineStaticState + >; + } + : { + /** + * Executes only the initial search for a given configuration, then returns a resumable snapshot of engine state along with the state of the controllers. + * + * Useful for static generation and server-side rendering. + */ + ( + ...params: OptionsTuple< + FetchStaticStateOptions & + EngineDefinitionControllersPropsOption + > + ): Promise>; + + fromBuildResult: FromBuildResult< + TControllers, + FetchStaticStateOptions, + EngineStaticState + >; + }; diff --git a/packages/headless/src/app/commerce-ssr-engine/types/from-build-result.ts b/packages/headless/src/app/commerce-ssr-engine/types/from-build-result.ts new file mode 100644 index 00000000000..b8cdace3642 --- /dev/null +++ b/packages/headless/src/app/commerce-ssr-engine/types/from-build-result.ts @@ -0,0 +1,26 @@ +import {ControllersMap} from '../../ssr-engine/types/common.js'; +import {SSRCommerceEngine} from '../factories/build-factory.js'; +import {EngineDefinitionBuildResult} from './common.js'; + +export interface FromBuildResultOptions { + /** + * The build result of the engine + */ + buildResult: EngineDefinitionBuildResult; + /** + * An optional array of keys representing the recommendation controllers to refresh. + * If a recommendation key defined in your engine definition is present in this list, the associate recommendation controller + * will query the API. + * + * This is applicable only if the engine is a recommendation engine. + */ + allowedRecommendationKeys?: (keyof TControllers)[]; +} + +export interface FromBuildResult< + TControllers extends ControllersMap, + TOptions, + TReturn, +> { + (options: FromBuildResultOptions & TOptions): Promise; +} diff --git a/packages/headless/src/app/commerce-ssr-engine/types/hydrate-static-state.ts b/packages/headless/src/app/commerce-ssr-engine/types/hydrate-static-state.ts new file mode 100644 index 00000000000..55196960e04 --- /dev/null +++ b/packages/headless/src/app/commerce-ssr-engine/types/hydrate-static-state.ts @@ -0,0 +1,59 @@ +import type {UnknownAction} from '@reduxjs/toolkit'; +import type { + ControllersMap, + ControllersPropsMap, + HydratedState, + OptionsTuple, +} from '../../ssr-engine/types/common.js'; +import {SSRCommerceEngine} from '../factories/build-factory.js'; +import { + EngineDefinitionControllersPropsOption, + SolutionType, +} from './common.js'; +import {FromBuildResult} from './from-build-result.js'; + +export interface HydrateStaticStateOptions { + searchActions: TSearchAction[]; +} + +export type HydrateStaticState< + TControllers extends ControllersMap, + TSearchAction extends UnknownAction, + TControllersProps extends ControllersPropsMap, + TSolutionType extends SolutionType, +> = TSolutionType extends SolutionType.recommendation + ? { + /** + * Creates a new engine from the snapshot of the engine created in SSR with fetchStaticState. + * + * Useful when hydrating a server-side-rendered engine. + */ + ( + ...params: OptionsTuple> + ): Promise>; + + fromBuildResult: FromBuildResult< + TControllers, + HydrateStaticStateOptions, + HydratedState + >; + } + : { + /** + * Creates a new engine from the snapshot of the engine created in SSR with fetchStaticState. + * + * Useful when hydrating a server-side-rendered engine. + */ + ( + ...params: OptionsTuple< + HydrateStaticStateOptions & + EngineDefinitionControllersPropsOption + > + ): Promise>; + + fromBuildResult: FromBuildResult< + TControllers, + HydrateStaticStateOptions, + HydratedState + >; + }; diff --git a/packages/headless/src/app/navigatorContextProvider.ts b/packages/headless/src/app/navigatorContextProvider.ts index 96d3ad6893a..f09885af2a9 100644 --- a/packages/headless/src/app/navigatorContextProvider.ts +++ b/packages/headless/src/app/navigatorContextProvider.ts @@ -1,7 +1,24 @@ +/** + * The `NavigatorContext` interface represents the context of the browser client. + */ export interface NavigatorContext { + /** + * The URL of the page that referred the user to the current page. + * See [Referer](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer) + */ referrer: string | null; + /** + * The user agent string of the browser that made the request. + * See [User-Agent](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent) + */ userAgent: string | null; + /** + * The URL of the current page. + */ location: string | null; + /** The unique identifier of the browser client in a Coveo-powered page. + * See [clientId](https://docs.coveo.com/en/masb0234). + */ clientId: string; } diff --git a/packages/headless/src/app/ssr-engine/types/from-build-result.ts b/packages/headless/src/app/ssr-engine/types/from-build-result.ts index 5851651b59b..fab3bd03029 100644 --- a/packages/headless/src/app/ssr-engine/types/from-build-result.ts +++ b/packages/headless/src/app/ssr-engine/types/from-build-result.ts @@ -6,6 +6,7 @@ export interface FromBuildResultOptions< TControllers extends ControllersMap, > { buildResult: EngineDefinitionBuildResult; + allowedRecommendationKeys?: (keyof TControllers)[]; } export interface FromBuildResult< diff --git a/packages/headless/src/controllers/commerce/core/breadcrumb-manager/headless-core-breadcrumb-manager.ssr.ts b/packages/headless/src/controllers/commerce/core/breadcrumb-manager/headless-core-breadcrumb-manager.ssr.ts index 6557cfc3c12..8416e622351 100644 --- a/packages/headless/src/controllers/commerce/core/breadcrumb-manager/headless-core-breadcrumb-manager.ssr.ts +++ b/packages/headless/src/controllers/commerce/core/breadcrumb-manager/headless-core-breadcrumb-manager.ssr.ts @@ -25,6 +25,8 @@ export function defineBreadcrumbManager< >(options?: TOptions) { ensureAtLeastOneSolutionType(options); return { + listing: true, + search: true, ...options, build: (engine, solutionType) => solutionType === SolutionType.listing diff --git a/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.ts b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.ts index d008d0039b8..d68d9c8b07a 100644 --- a/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.ts +++ b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.ts @@ -113,6 +113,8 @@ export function defineFacetGenerator< >(options?: TOptions) { ensureAtLeastOneSolutionType(options); return { + listing: true, + search: true, ...options, build: (engine, solutionType) => buildFacetGenerator(engine, {props: {solutionType: solutionType!}}), diff --git a/packages/headless/src/controllers/commerce/core/pagination/headless-core-commerce-pagination.ssr.ts b/packages/headless/src/controllers/commerce/core/pagination/headless-core-commerce-pagination.ssr.ts index 10e29dd8a9d..0fe04c67d38 100644 --- a/packages/headless/src/controllers/commerce/core/pagination/headless-core-commerce-pagination.ssr.ts +++ b/packages/headless/src/controllers/commerce/core/pagination/headless-core-commerce-pagination.ssr.ts @@ -28,6 +28,8 @@ export function definePagination< >(props?: PaginationProps & TOptions) { ensureAtLeastOneSolutionType(props); return { + listing: true, + search: true, ...props, build: (engine, solutionType) => solutionType === SolutionType.listing diff --git a/packages/headless/src/controllers/commerce/core/parameter-manager/headless-core-parameter-manager.ssr.ts b/packages/headless/src/controllers/commerce/core/parameter-manager/headless-core-parameter-manager.ssr.ts index 2a4917db7d0..fa85bfd07f0 100644 --- a/packages/headless/src/controllers/commerce/core/parameter-manager/headless-core-parameter-manager.ssr.ts +++ b/packages/headless/src/controllers/commerce/core/parameter-manager/headless-core-parameter-manager.ssr.ts @@ -46,6 +46,8 @@ export function defineParameterManager< >(options?: TOptions) { ensureAtLeastOneSolutionType(options); return { + listing: true, + search: true, ...options, buildWithProps: (engine, props, solutionType) => { if (solutionType === SolutionType.listing) { diff --git a/packages/headless/src/controllers/commerce/core/sort/headless-core-commerce-sort.ssr.ts b/packages/headless/src/controllers/commerce/core/sort/headless-core-commerce-sort.ssr.ts index 24743cd3ef9..126497a8580 100644 --- a/packages/headless/src/controllers/commerce/core/sort/headless-core-commerce-sort.ssr.ts +++ b/packages/headless/src/controllers/commerce/core/sort/headless-core-commerce-sort.ssr.ts @@ -24,6 +24,8 @@ export function defineSort< >(props?: SortProps & TOptions) { ensureAtLeastOneSolutionType(props); return { + listing: true, + search: true, ...props, build: (engine, solutionType) => solutionType === SolutionType.listing diff --git a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ssr.ts b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ssr.ts index 066a3f38690..356d4caf5cf 100644 --- a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ssr.ts +++ b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ssr.ts @@ -17,6 +17,8 @@ export function defineQuerySummary< >(options?: TOptions) { ensureAtLeastOneSolutionType(options); return { + listing: true, + search: true, ...options, build: (engine, solutionType) => solutionType === SolutionType.listing diff --git a/packages/headless/src/controllers/commerce/core/summary/headless-core-summary.ssr.ts b/packages/headless/src/controllers/commerce/core/summary/headless-core-summary.ssr.ts index d8f410d8d4c..85e788de36a 100644 --- a/packages/headless/src/controllers/commerce/core/summary/headless-core-summary.ssr.ts +++ b/packages/headless/src/controllers/commerce/core/summary/headless-core-summary.ssr.ts @@ -32,6 +32,8 @@ export function defineSummary< >(options?: TOptions) { ensureAtLeastOneSolutionType(options); return { + listing: true, + search: true, ...options, build: (engine, solutionType) => solutionType === SolutionType.listing diff --git a/packages/headless/src/controllers/commerce/product-list/headless-product-list.ssr.ts b/packages/headless/src/controllers/commerce/product-list/headless-product-list.ssr.ts index df540b83c34..d8c559f0137 100644 --- a/packages/headless/src/controllers/commerce/product-list/headless-product-list.ssr.ts +++ b/packages/headless/src/controllers/commerce/product-list/headless-product-list.ssr.ts @@ -29,6 +29,8 @@ export function defineProductList< >(options?: TOptions) { ensureAtLeastOneSolutionType(options); return { + listing: true, + search: true, ...options, build: (engine, solutionType) => solutionType === SolutionType.listing diff --git a/packages/headless/src/controllers/commerce/recommendations/headless-recommendations.ssr.ts b/packages/headless/src/controllers/commerce/recommendations/headless-recommendations.ssr.ts index 2996ef6e9de..23c6b0162ba 100644 --- a/packages/headless/src/controllers/commerce/recommendations/headless-recommendations.ssr.ts +++ b/packages/headless/src/controllers/commerce/recommendations/headless-recommendations.ssr.ts @@ -1,4 +1,4 @@ -import {UniversalControllerDefinitionWithoutProps} from '../../../app/commerce-ssr-engine/types/common.js'; +import {RecommendationOnlyControllerDefinitionWithoutProps} from '../../../app/commerce-ssr-engine/types/common.js'; import {RecommendationsState} from '../recommendations/headless-recommendations.js'; import { RecommendationsProps, @@ -8,8 +8,15 @@ import { export type {Recommendations, RecommendationsState}; +/** + * @internal + * */ +export type RecommendationsDefinitionMeta = { + options: {} & RecommendationsProps['options']; +}; + export interface RecommendationsDefinition - extends UniversalControllerDefinitionWithoutProps {} + extends RecommendationOnlyControllerDefinitionWithoutProps {} /** * @internal * Defines a `Recommendations` controller instance. @@ -20,11 +27,12 @@ export interface RecommendationsDefinition * */ export function defineRecommendations( props: RecommendationsProps -): RecommendationsDefinition { +): RecommendationsDefinition & RecommendationsDefinitionMeta { return { - search: true, - listing: true, - standalone: true, + recommendation: true, + options: { + ...props.options, + }, build: (engine) => buildRecommendations(engine, props), }; } diff --git a/packages/headless/src/ssr-commerce.index.ts b/packages/headless/src/ssr-commerce.index.ts index 81bb378398a..90b84742857 100644 --- a/packages/headless/src/ssr-commerce.index.ts +++ b/packages/headless/src/ssr-commerce.index.ts @@ -67,17 +67,18 @@ * @module SSR Commerce */ +export type { + CommerceEngineDefinitionOptions, + SSRCommerceEngine as CommerceEngine, +} from './app/commerce-ssr-engine/factories/build-factory.js'; + export type {Unsubscribe, Middleware} from '@reduxjs/toolkit'; export type {Relay} from '@coveo/relay'; // Main App export type {CommerceEngineOptions} from './app/commerce-engine/commerce-engine.js'; export type {CommerceEngineConfiguration} from './app/commerce-engine/commerce-engine-configuration.js'; -export type { - SSRCommerceEngine as CommerceEngine, - CommerceEngineDefinition, - CommerceEngineDefinitionOptions, -} from './app/commerce-engine/commerce-engine.ssr.js'; +export type {CommerceEngineDefinition} from './app/commerce-engine/commerce-engine.ssr.js'; export {defineCommerceEngine} from './app/commerce-engine/commerce-engine.ssr.js'; export {getSampleCommerceEngineConfiguration} from './app/commerce-engine/commerce-engine-configuration.js'; @@ -97,8 +98,12 @@ export type { InferControllerStaticStateMapFromControllers, InferControllerStaticStateMapFromDefinitionsWithSolutionType, InferControllerPropsMapFromDefinitions, + EngineStaticState, + EngineDefinitionBuildResult, + EngineDefinitionControllersPropsOption, + HydratedState, + OptionsTuple, } from './app/commerce-ssr-engine/types/common.js'; -export type {Build} from './app/ssr-engine/types/build.js'; export type { EngineDefinition, InferStaticState, @@ -106,6 +111,12 @@ export type { InferBuildResult, HydrateStaticState, FetchStaticState, + FetchStaticStateOptions, + HydrateStaticStateOptions, + Build, + BuildOptions, + FromBuildResult, + FromBuildResultOptions, } from './app/commerce-ssr-engine/types/core-engine.js'; export type {LoggerOptions} from './app/logger.js'; export type { diff --git a/packages/headless/src/test/mock-engine-v2.ts b/packages/headless/src/test/mock-engine-v2.ts index dd43748f7a7..866548e7200 100644 --- a/packages/headless/src/test/mock-engine-v2.ts +++ b/packages/headless/src/test/mock-engine-v2.ts @@ -3,7 +3,7 @@ import {pino, Logger} from 'pino'; import {vi, Mock} from 'vitest'; import {CaseAssistEngine} from '../app/case-assist-engine/case-assist-engine.js'; import {CommerceEngine} from '../app/commerce-engine/commerce-engine.js'; -import {SSRCommerceEngine} from '../app/commerce-engine/commerce-engine.ssr.js'; +import {SSRCommerceEngine} from '../app/commerce-ssr-engine/factories/build-factory.js'; import type {CoreEngine, CoreEngineNext} from '../app/engine.js'; import {InsightEngine} from '../app/insight-engine/insight-engine.js'; import {defaultNodeJSNavigatorContextProvider} from '../app/navigatorContextProvider.js'; diff --git a/packages/headless/src/utils/utils.ts b/packages/headless/src/utils/utils.ts index 92af128c4d1..99af274dd43 100644 --- a/packages/headless/src/utils/utils.ts +++ b/packages/headless/src/utils/utils.ts @@ -1,4 +1,5 @@ -import {Middleware, Action} from '@reduxjs/toolkit'; +import {Middleware, Action, PayloadAction} from '@reduxjs/toolkit'; +import {FetchRecommendationsPayload} from '../features/commerce/recommendations/recommendations-actions.js'; export const randomID = (prepend?: string, length = 5) => prepend + @@ -157,3 +158,48 @@ export function createWaitForActionMiddleware( return {promise, middleware}; } + +function isRecommendationActionPayload

( + action: unknown +): action is PayloadAction { + if (action === null || action === undefined) { + return false; + } + + if (typeof action === 'object' && 'meta' in action) { + return ( + (action as PayloadAction).meta + ?.arg?.slotId !== undefined + ); + } + + return false; +} + +export function createWaitForActionMiddlewareForRecommendation< + TAction extends Action, +>( + isDesiredAction: (action: unknown) => action is TAction, + memo: Set +): {promise: Promise; middleware: Middleware} { + const {promise, resolve} = createDeferredPromise(); + let hasBeenResolved = false; + const hasSlotBeenProcessed = (slotId: string) => memo.has(slotId); + + const middleware: Middleware = () => (next) => (action) => { + next(action); + + if ( + isDesiredAction(action) && + !hasBeenResolved && + isRecommendationActionPayload(action) && + !hasSlotBeenProcessed(action.meta.arg.slotId) + ) { + hasBeenResolved = true; + memo.add(action.meta.arg.slotId); + resolve(action); + } + }; + + return {promise, middleware}; +} diff --git a/packages/samples/headless-ssr-commerce/app/(listing)/[category]/page.tsx b/packages/samples/headless-ssr-commerce/app/(listing)/[category]/page.tsx index 740a6869fa6..ec0c9bae99e 100644 --- a/packages/samples/headless-ssr-commerce/app/(listing)/[category]/page.tsx +++ b/packages/samples/headless-ssr-commerce/app/(listing)/[category]/page.tsx @@ -6,11 +6,16 @@ import FacetGenerator from '@/components/facets/facet-generator'; import Pagination from '@/components/pagination'; import ProductList from '@/components/product-list'; import ListingProvider from '@/components/providers/listing-provider'; -import Recommendations from '@/components/recommendation-list'; +import RecommendationProvider from '@/components/providers/recommendation-provider'; +import PopularBought from '@/components/recommendations/popular-bought'; +import PopularViewed from '@/components/recommendations/popular-viewed'; import Sort from '@/components/sort'; import StandaloneSearchBox from '@/components/standalone-search-box'; import Summary from '@/components/summary'; -import {listingEngineDefinition} from '@/lib/commerce-engine'; +import { + listingEngineDefinition, + recommendationEngineDefinition, +} from '@/lib/commerce-engine'; import {NextJsNavigatorContext} from '@/lib/navigatorContextProvider'; import {defaultContext} from '@/utils/context'; import {headers} from 'next/headers'; @@ -54,6 +59,10 @@ export default async function Listing({params}: {params: {category: string}}) { }, }); + const recsStaticState = await recommendationEngineDefinition.fetchStaticState( + ['popularBought', 'popularViewed'] + ); + return (

- + + + +
diff --git a/packages/samples/headless-ssr-commerce/app/cart/page.tsx b/packages/samples/headless-ssr-commerce/app/cart/page.tsx index 9713f817bb4..bc994c0db1e 100644 --- a/packages/samples/headless-ssr-commerce/app/cart/page.tsx +++ b/packages/samples/headless-ssr-commerce/app/cart/page.tsx @@ -1,8 +1,13 @@ import * as externalCartAPI from '@/actions/external-cart-api'; import Cart from '@/components/cart'; import ContextDropdown from '@/components/context-dropdown'; +import RecommendationProvider from '@/components/providers/recommendation-provider'; import SearchProvider from '@/components/providers/search-provider'; -import {searchEngineDefinition} from '@/lib/commerce-engine'; +import PopularBought from '@/components/recommendations/popular-bought'; +import { + recommendationEngineDefinition, + searchEngineDefinition, +} from '@/lib/commerce-engine'; import {NextJsNavigatorContext} from '@/lib/navigatorContextProvider'; import {defaultContext} from '@/utils/context'; import {headers} from 'next/headers'; @@ -30,6 +35,9 @@ export default async function Search() { }, }); + const recsStaticState = await recommendationEngineDefinition.fetchStaticState( + ['popularBought'] + ); return ( + + + ); diff --git a/packages/samples/headless-ssr-commerce/app/search/page.tsx b/packages/samples/headless-ssr-commerce/app/search/page.tsx index 6458b16d347..c3917a7bce0 100644 --- a/packages/samples/headless-ssr-commerce/app/search/page.tsx +++ b/packages/samples/headless-ssr-commerce/app/search/page.tsx @@ -4,7 +4,6 @@ import ContextDropdown from '@/components/context-dropdown'; import FacetGenerator from '@/components/facets/facet-generator'; import ProductList from '@/components/product-list'; import SearchProvider from '@/components/providers/search-provider'; -import Recommendations from '@/components/recommendation-list'; import SearchBox from '@/components/search-box'; import ShowMore from '@/components/show-more'; import Summary from '@/components/summary'; @@ -60,12 +59,6 @@ export default async function Search() { > */} - -
- {/* popularBoughtRecs */} - {/* TODO: KIT-3503: need to revisit the way recommendations are added*/} - -
); diff --git a/packages/samples/headless-ssr-commerce/components/cart.tsx b/packages/samples/headless-ssr-commerce/components/cart.tsx index c5106df9b74..44f0cf308f4 100644 --- a/packages/samples/headless-ssr-commerce/components/cart.tsx +++ b/packages/samples/headless-ssr-commerce/components/cart.tsx @@ -17,7 +17,7 @@ export default function Cart() { return (
-
    +
      {state.items.map((item, index) => (
    • diff --git a/packages/samples/headless-ssr-commerce/components/pages/product-page.tsx b/packages/samples/headless-ssr-commerce/components/pages/product-page.tsx index 5f69987bdfe..79232674ef2 100644 --- a/packages/samples/headless-ssr-commerce/components/pages/product-page.tsx +++ b/packages/samples/headless-ssr-commerce/components/pages/product-page.tsx @@ -8,7 +8,6 @@ import { import {NavigatorContext} from '@coveo/headless-react/ssr-commerce'; import {useSearchParams} from 'next/navigation'; import {useEffect, useState} from 'react'; -import Recommendations from '../recommendation-list'; interface IProductPageProps { staticState: StandaloneStaticState; @@ -36,7 +35,7 @@ export default function ProductPage(props: IProductPageProps) { useEffect(() => { standaloneEngineDefinition .hydrateStaticState({ - searchAction: staticState.searchAction, + searchActions: staticState.searchActions, controllers: { cart: { initialState: {items: staticState.controllers.cart.state.items}, @@ -46,10 +45,6 @@ export default function ProductPage(props: IProductPageProps) { }) .then(({engine, controllers}) => { setHydratedState({engine, controllers}); - - // Refreshing recommendations in the browser after hydrating the state in the client-side - // Recommendation refresh in the server is not supported yet. - controllers.popularBoughtRecs.refresh(); }); }, [staticState]); @@ -65,7 +60,6 @@ export default function ProductPage(props: IProductPageProps) { {name} ({productId}) - ${price}


      - ); } diff --git a/packages/samples/headless-ssr-commerce/components/product-button-with-image.tsx b/packages/samples/headless-ssr-commerce/components/product-button-with-image.tsx new file mode 100644 index 00000000000..56815b477d7 --- /dev/null +++ b/packages/samples/headless-ssr-commerce/components/product-button-with-image.tsx @@ -0,0 +1,41 @@ +import { + Product, + ProductList, + Recommendations, +} from '@coveo/headless-react/ssr-commerce'; +import Image from 'next/image'; +import {useRouter} from 'next/navigation'; + +export interface ProductButtonWithImageProps { + methods: + | Omit + | Omit + | undefined; + product: Product; +} + +export default function ProductButtonWithImage({ + methods, + product, +}: ProductButtonWithImageProps) { + const router = useRouter(); + + const onProductClick = (product: Product) => { + methods?.interactiveProduct({options: {product}}).select(); + router.push( + `/products/${product.ec_product_id}?name=${product.ec_name}&price=${product.ec_price}` + ); + }; + + return ( + + ); +} diff --git a/packages/samples/headless-ssr-commerce/components/product-list.tsx b/packages/samples/headless-ssr-commerce/components/product-list.tsx index 7b21f6190d8..f18c5674e42 100644 --- a/packages/samples/headless-ssr-commerce/components/product-list.tsx +++ b/packages/samples/headless-ssr-commerce/components/product-list.tsx @@ -2,36 +2,18 @@ import {useCart, useProductList} from '@/lib/commerce-engine'; import {addToCart} from '@/utils/cart'; -import {Product} from '@coveo/headless-react/ssr-commerce'; -import Image from 'next/image'; -import {useRouter} from 'next/navigation'; +import ProductButtonWithImage from './product-button-with-image'; export default function ProductList() { const {state, methods} = useProductList(); const {state: cartState, methods: cartMethods} = useCart(); - const router = useRouter(); - - const onProductClick = (product: Product) => { - methods?.interactiveProduct({options: {product}}).select(); - router.push( - `/products/${product.ec_product_id}?name=${product.ec_name}&price=${product.ec_price}` - ); - }; - return (
        {state.products.map((product) => (
      • - + + -
      • - ))} -
      - - ); -} diff --git a/packages/samples/headless-ssr-commerce/components/recommendations/popular-bought.tsx b/packages/samples/headless-ssr-commerce/components/recommendations/popular-bought.tsx new file mode 100644 index 00000000000..558d76b1a84 --- /dev/null +++ b/packages/samples/headless-ssr-commerce/components/recommendations/popular-bought.tsx @@ -0,0 +1,21 @@ +'use client'; + +import {usePopularBought} from '@/lib/commerce-engine'; +import ProductButtonWithImage from '../product-button-with-image'; + +export default function PopularBought() { + const {state, methods} = usePopularBought(); + + return ( + <> +
        +

        {state.headline}

        + {state.products.map((product) => ( +
      • + +
      • + ))} +
      + + ); +} diff --git a/packages/samples/headless-ssr-commerce/components/recommendations/popular-viewed.tsx b/packages/samples/headless-ssr-commerce/components/recommendations/popular-viewed.tsx new file mode 100644 index 00000000000..14f8bb82ca6 --- /dev/null +++ b/packages/samples/headless-ssr-commerce/components/recommendations/popular-viewed.tsx @@ -0,0 +1,21 @@ +'use client'; + +import {usePopularViewed} from '@/lib/commerce-engine'; +import ProductButtonWithImage from '../product-button-with-image'; + +export default function PopularViewed() { + const {state, methods} = usePopularViewed(); + + return ( + <> +
        +

        {state.headline}

        + {state.products.map((product) => ( +
      • + +
      • + ))} +
      + + ); +} diff --git a/packages/samples/headless-ssr-commerce/e2e/cart/cart.spec.ts b/packages/samples/headless-ssr-commerce/e2e/cart/cart.spec.ts index b4aee30e07d..34ed42720ae 100644 --- a/packages/samples/headless-ssr-commerce/e2e/cart/cart.spec.ts +++ b/packages/samples/headless-ssr-commerce/e2e/cart/cart.spec.ts @@ -56,7 +56,7 @@ test.describe('default', () => { const cartItemsCount = await cart.items.count(); - expect(cartItemsCount).toBe(4); + expect(cartItemsCount).toBe(3); }); }); @@ -221,6 +221,7 @@ test.describe('default', () => { }); }); }); + test.describe('ssr', () => { const numItemsInCart = 0; // Define the numResults variable const numItemsInCartMsg = `Items in cart: ${numItemsInCart}`; @@ -238,7 +239,7 @@ test.describe('ssr', () => { numItemsInCartMsg ); - expect(dom.window.document.querySelectorAll('ul li').length).toBe( + expect(dom.window.document.querySelectorAll('ul#cart li').length).toBe( numItemsInCart ); expect( diff --git a/packages/samples/headless-ssr-commerce/e2e/page-objects/cart.page.ts b/packages/samples/headless-ssr-commerce/e2e/page-objects/cart.page.ts index 50a12e2c4f6..002fd38cddc 100644 --- a/packages/samples/headless-ssr-commerce/e2e/page-objects/cart.page.ts +++ b/packages/samples/headless-ssr-commerce/e2e/page-objects/cart.page.ts @@ -13,7 +13,7 @@ export class CartPageObject { get items() { const cart = this.cart; - return cart.locator('ul > li'); + return cart.locator('ul#cart > li'); } async getItemQuantity(item: Locator) { diff --git a/packages/samples/headless-ssr-commerce/lib/commerce-engine-config.ts b/packages/samples/headless-ssr-commerce/lib/commerce-engine-config.ts index 941a10cd5bb..e79c85001a6 100644 --- a/packages/samples/headless-ssr-commerce/lib/commerce-engine-config.ts +++ b/packages/samples/headless-ssr-commerce/lib/commerce-engine-config.ts @@ -36,12 +36,12 @@ export default { controllers: { summary: defineSummary(), productList: defineProductList(), - popularViewedRecs: defineRecommendations({ + popularViewed: defineRecommendations({ options: { slotId: 'd73afbd2-8521-4ee6-a9b8-31f064721e73', }, }), - popularBoughtRecs: defineRecommendations({ + popularBought: defineRecommendations({ options: { slotId: 'af4fb7ba-6641-4b67-9cf9-be67e9f30174', }, diff --git a/packages/samples/headless-ssr-commerce/lib/commerce-engine.ts b/packages/samples/headless-ssr-commerce/lib/commerce-engine.ts index 91ea7d56473..ad6db361dff 100644 --- a/packages/samples/headless-ssr-commerce/lib/commerce-engine.ts +++ b/packages/samples/headless-ssr-commerce/lib/commerce-engine.ts @@ -10,6 +10,7 @@ export const engineDefinition = defineCommerceEngine(engineConfig); export const { listingEngineDefinition, searchEngineDefinition, + recommendationEngineDefinition, standaloneEngineDefinition, useEngine, } = engineDefinition; @@ -22,8 +23,8 @@ export const { useInstantProducts, useNotifyTrigger, usePagination, - usePopularBoughtRecs, - usePopularViewedRecs, + usePopularBought, + usePopularViewed, useProductView, useQueryTrigger, useRecentQueriesList, @@ -48,6 +49,13 @@ export type SearchHydratedState = InferHydratedState< typeof searchEngineDefinition >; +export type RecommendationStaticState = InferStaticState< + typeof recommendationEngineDefinition +>; +export type RecommendationHydratedState = InferHydratedState< + typeof recommendationEngineDefinition +>; + export type StandaloneStaticState = InferStaticState< typeof standaloneEngineDefinition >; diff --git a/packages/samples/headless-ssr-commerce/package.json b/packages/samples/headless-ssr-commerce/package.json index ab8dacbbf82..718070a2460 100644 --- a/packages/samples/headless-ssr-commerce/package.json +++ b/packages/samples/headless-ssr-commerce/package.json @@ -20,6 +20,7 @@ "@types/node": "20.14.12", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", + "@playwright/test": "1.45.3", "eslint": "8.57", "eslint-config-next": "14.2.5", "jsdom": "25.0.1",