diff --git a/packages/headless/src/commerce.index.ts b/packages/headless/src/commerce.index.ts index 522be82aec2..15cfe552c97 100644 --- a/packages/headless/src/commerce.index.ts +++ b/packages/headless/src/commerce.index.ts @@ -83,6 +83,7 @@ export * from './features/commerce/recommendations/recommendations-actions-loade export * from './features/commerce/search/search-actions-loader.js'; export * from './features/commerce/search-parameters/search-parameters-actions-loader.js'; export * from './features/commerce/sort/sort-actions-loader.js'; +export * from './features/commerce/spotlight-content/spotlight-content-actions-loaders.js'; export * from './features/commerce/standalone-search-box-set/standalone-search-box-set-actions-loader.js'; export * from './features/commerce/triggers/triggers-actions-loader.js'; export type {HighlightKeyword} from './utils/highlight.js'; @@ -177,6 +178,11 @@ export type { InteractiveProductOptions, InteractiveProductProps, } from './controllers/commerce/core/interactive-product/headless-core-interactive-product.js'; +export type { + InteractiveSpotlightContent, + InteractiveSpotlightContentOptions, + InteractiveSpotlightContentProps, +} from './controllers/commerce/core/interactive-spotlight-content/headless-core-interactive-spotlight-content.js'; export type { Pagination, PaginationOptions, diff --git a/packages/headless/src/controllers/commerce/core/interactive-product/headless-core-interactive-product.ts b/packages/headless/src/controllers/commerce/core/interactive-product/headless-core-interactive-product.ts index 35ef6e6bbd8..235e4253f05 100644 --- a/packages/headless/src/controllers/commerce/core/interactive-product/headless-core-interactive-product.ts +++ b/packages/headless/src/controllers/commerce/core/interactive-product/headless-core-interactive-product.ts @@ -71,7 +71,7 @@ export function buildCoreInteractiveProduct( `- Could not retrieve '${property}' analytics property from field${lookupFields.length > 1 ? 's' : ''} \ '${lookupFields.join("', '")}'; fell back to ${fallback}.`; - const warnings = []; + const warnings: string[] = []; const {ec_name, ec_promo_price, ec_price, ec_product_id} = props.options.product; diff --git a/packages/headless/src/controllers/commerce/core/interactive-spotlight-content/headless-core-interactive-spotlight-content.test.ts b/packages/headless/src/controllers/commerce/core/interactive-spotlight-content/headless-core-interactive-spotlight-content.test.ts new file mode 100644 index 00000000000..e51a72d6c86 --- /dev/null +++ b/packages/headless/src/controllers/commerce/core/interactive-spotlight-content/headless-core-interactive-spotlight-content.test.ts @@ -0,0 +1,207 @@ +import {configuration} from '../../../../app/common-reducers.js'; +import {spotlightContentClick} from '../../../../features/commerce/spotlight-content/spotlight-content-actions.js'; +import {buildMockCommerceState} from '../../../../test/mock-commerce-state.js'; +import { + buildMockCommerceEngine, + type MockedCommerceEngine, +} from '../../../../test/mock-engine-v2.js'; +import {buildMockSpotlightContent} from '../../../../test/mock-spotlight-content.js'; +import {buildCoreInteractiveSpotlightContent} from './headless-core-interactive-spotlight-content.js'; + +vi.mock( + '../../../../features/commerce/spotlight-content/spotlight-content-actions' +); + +describe('core interactive spotlight content', () => { + let engine: MockedCommerceEngine; + + const spotlightContent = buildMockSpotlightContent({ + id: 'spotlight-1-id', + desktopImage: 'https://example.com/desktop.jpg', + position: 1, + responseId: 'spotlight-response-id', + }); + + function initializeInteractiveSpotlightContent() { + buildCoreInteractiveSpotlightContent(engine, { + options: { + spotlightContent, + }, + responseIdSelector: () => 'state-response-id', + }); + } + + beforeEach(() => { + engine = buildMockCommerceEngine(buildMockCommerceState()); + initializeInteractiveSpotlightContent(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('adds the correct reducers to engine', () => { + expect(engine.addReducers).toHaveBeenCalledWith({configuration}); + }); + + describe('#select', () => { + it('when id and desktopImage are defined on the spotlight content, dispatches spotlightContentClick with the correct payload', () => { + const controller = buildCoreInteractiveSpotlightContent(engine, { + options: { + spotlightContent, + }, + responseIdSelector: () => 'responseId', + }); + + controller.select(); + + expect(spotlightContentClick).toHaveBeenCalledWith({ + id: spotlightContent.id, + desktopImage: spotlightContent.desktopImage, + position: spotlightContent.position, + responseId: 'spotlight-response-id', + }); + }); + + it('when spotlight content has responseId, uses spotlight content responseId instead of responseIdSelector', () => { + const controller = buildCoreInteractiveSpotlightContent(engine, { + options: { + spotlightContent, + }, + responseIdSelector: () => 'state-response-id', + }); + + controller.select(); + + expect(spotlightContentClick).toHaveBeenCalledWith({ + id: spotlightContent.id, + desktopImage: spotlightContent.desktopImage, + position: spotlightContent.position, + responseId: 'spotlight-response-id', + }); + }); + + it('when spotlight content has no responseId, falls back to responseIdSelector', () => { + const spotlightContentWithoutResponseId = buildMockSpotlightContent({ + ...spotlightContent, + responseId: undefined, + }); + + const controller = buildCoreInteractiveSpotlightContent(engine, { + options: { + spotlightContent: spotlightContentWithoutResponseId, + }, + responseIdSelector: () => 'state-response-id', + }); + + controller.select(); + + expect(spotlightContentClick).toHaveBeenCalledWith({ + id: spotlightContentWithoutResponseId.id, + desktopImage: spotlightContentWithoutResponseId.desktopImage, + position: spotlightContentWithoutResponseId.position, + responseId: 'state-response-id', + }); + }); + + it('does not dispatch action on multiple calls', () => { + const controller = buildCoreInteractiveSpotlightContent(engine, { + options: { + spotlightContent, + }, + responseIdSelector: () => 'state-response-id', + }); + + controller.select(); + controller.select(); + + expect(spotlightContentClick).toHaveBeenCalledTimes(1); + }); + }); + + describe('#beginDelayedSelect', () => { + it('dispatches #spotlightContentClick after the given delay', () => { + vi.useFakeTimers(); + const controller = buildCoreInteractiveSpotlightContent(engine, { + options: { + spotlightContent, + selectionDelay: 1000, + }, + responseIdSelector: () => 'state-response-id', + }); + + controller.beginDelayedSelect(); + vi.advanceTimersByTime(1000); + + expect(spotlightContentClick).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + }); + + describe('#cancelPendingSelect', () => { + it('cancels the pending #spotlightContentClick action', () => { + vi.useFakeTimers(); + const controller = buildCoreInteractiveSpotlightContent(engine, { + options: { + spotlightContent, + selectionDelay: 1000, + }, + responseIdSelector: () => 'state-response-id', + }); + + controller.beginDelayedSelect(); + controller.cancelPendingSelect(); + vi.advanceTimersByTime(1000); + + expect(spotlightContentClick).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + }); + + describe('#warningMessage', () => { + it('when id is missing, returns a warning message', () => { + const spotlightContentWithoutId = buildMockSpotlightContent({ + ...spotlightContent, + id: '', + }); + + const controller = buildCoreInteractiveSpotlightContent(engine, { + options: { + spotlightContent: spotlightContentWithoutId, + }, + responseIdSelector: () => 'state-response-id', + }); + + expect(controller.warningMessage).toBeDefined(); + expect(controller.warningMessage).toContain('id'); + }); + + it('when desktopImage is missing, returns a warning message', () => { + const spotlightContentWithoutImage = buildMockSpotlightContent({ + ...spotlightContent, + desktopImage: '', + }); + + const controller = buildCoreInteractiveSpotlightContent(engine, { + options: { + spotlightContent: spotlightContentWithoutImage, + }, + responseIdSelector: () => 'state-response-id', + }); + + expect(controller.warningMessage).toBeDefined(); + expect(controller.warningMessage).toContain('desktopImage'); + }); + + it('when all required properties are present, returns undefined', () => { + const controller = buildCoreInteractiveSpotlightContent(engine, { + options: { + spotlightContent, + }, + responseIdSelector: () => 'state-response-id', + }); + + expect(controller.warningMessage).toBeUndefined(); + }); + }); +}); diff --git a/packages/headless/src/controllers/commerce/core/interactive-spotlight-content/headless-core-interactive-spotlight-content.ts b/packages/headless/src/controllers/commerce/core/interactive-spotlight-content/headless-core-interactive-spotlight-content.ts new file mode 100644 index 00000000000..6b1ff27cd2d --- /dev/null +++ b/packages/headless/src/controllers/commerce/core/interactive-spotlight-content/headless-core-interactive-spotlight-content.ts @@ -0,0 +1,110 @@ +import type {SpotlightContent} from '../../../../api/commerce/common/result.js'; +import type { + CommerceEngine, + CommerceEngineState, +} from '../../../../app/commerce-engine/commerce-engine.js'; +import {stateKey} from '../../../../app/state-key.js'; +import {spotlightContentClick} from '../../../../features/commerce/spotlight-content/spotlight-content-actions.js'; +import type { + InteractiveResultCore, + InteractiveResultCoreProps as InteractiveResultHeadlessCoreProps, + InteractiveResultCoreOptions as InteractiveSpotlightContentCoreOptions, +} from '../../../core/interactive-result/headless-core-interactive-result.js'; +import {buildInteractiveResultCore} from '../../../core/interactive-result/headless-core-interactive-result.js'; + +export interface InteractiveSpotlightContentOptions + extends InteractiveSpotlightContentCoreOptions { + /** + * The spotlight content to log analytics for. + */ + spotlightContent: SpotlightContent; +} + +export interface InteractiveSpotlightContentCoreProps + extends InteractiveResultHeadlessCoreProps { + /** + * The options for the `InteractiveSpotlightContent` sub-controller. + */ + options: InteractiveSpotlightContentOptions; + + /** + * The selector to fetch the response ID from the state. + */ + responseIdSelector: (state: CommerceEngineState) => string; +} + +export type InteractiveSpotlightContentProps = Omit< + InteractiveSpotlightContentCoreProps, + 'responseIdSelector' +>; + +/** + * The `InteractiveSpotlightContent` sub-controller provides an interface for handling long presses, multiple clicks, etc. to ensure + * analytics events are logged properly when a user selects a spotlight content item. + */ +export interface InteractiveSpotlightContent extends InteractiveResultCore { + warningMessage?: string; +} + +/** + * Creates an `InteractiveSpotlightContent` sub-controller instance. + * + * @param engine - The headless commerce engine. + * @param props - The configurable `InteractiveSpotlightContent` properties. + * @returns An `InteractiveSpotlightContent` sub-controller instance. + * + * @group Buildable controllers + * @category CoreInteractiveSpotlightContent + */ +export function buildCoreInteractiveSpotlightContent( + engine: CommerceEngine, + props: InteractiveSpotlightContentCoreProps +): InteractiveSpotlightContent { + let wasOpened = false; + + const getWarningMessage = () => { + const {id, desktopImage} = props.options.spotlightContent; + + const warnings: string[] = []; + + if (!id) { + warnings.push( + "- Could not retrieve 'id' property from spotlight content; this is required for analytics." + ); + } + if (!desktopImage) { + warnings.push( + "- Could not retrieve 'desktopImage' property from spotlight content; this is required for analytics." + ); + } + + if (warnings.length === 0) { + return; + } + + return `Some required analytics properties could not be retrieved from the spotlight content with \ +id '${id}':\n\n${warnings.join('\n')}\n\nReview the configuration to ensure the spotlight content contains the correct metadata.`; + }; + + const logAnalyticsIfNeverOpened = () => { + if (wasOpened) { + return; + } + wasOpened = true; + engine.dispatch( + spotlightContentClick({ + id: props.options.spotlightContent.id, + desktopImage: props.options.spotlightContent.desktopImage, + position: props.options.spotlightContent.position, + responseId: + props.options.spotlightContent.responseId ?? + props.responseIdSelector(engine[stateKey]), + }) + ); + }; + + return { + ...buildInteractiveResultCore(engine, props, logAnalyticsIfNeverOpened), + warningMessage: getWarningMessage(), + }; +} diff --git a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.test.ts b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.test.ts index d4909cdeffe..3732d353180 100644 --- a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.test.ts +++ b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.test.ts @@ -7,11 +7,13 @@ import { type MockedCommerceEngine, } from '../../../../test/mock-engine-v2.js'; import {buildMockProduct} from '../../../../test/mock-product.js'; +import {buildMockSpotlightContent} from '../../../../test/mock-spotlight-content.js'; import * as DidYouMean from '../../search/did-you-mean/headless-did-you-mean.js'; import type {SearchSummaryState} from '../../search/summary/headless-search-summary.js'; import * as CoreBreadcrumbManager from '../breadcrumb-manager/headless-core-breadcrumb-manager.js'; import * as CoreFacetGenerator from '../facets/generator/headless-commerce-facet-generator.js'; import * as CoreInteractiveProduct from '../interactive-product/headless-core-interactive-product.js'; +import * as CoreInteractiveSpotlightContent from '../interactive-spotlight-content/headless-core-interactive-spotlight-content.js'; import * as CorePagination from '../pagination/headless-core-commerce-pagination.js'; import * as CoreParameterManager from '../parameter-manager/headless-core-parameter-manager.js'; import * as CoreSort from '../sort/headless-core-commerce-sort.js'; @@ -19,6 +21,7 @@ import * as CoreUrlManager from '../url-manager/headless-core-url-manager.js'; import { type BaseSolutionTypeSubControllers, buildBaseSubControllers, + buildProductListingSubControllers, buildSearchAndListingsSubControllers, buildSearchSubControllers, type SearchAndListingSubControllers, @@ -106,6 +109,79 @@ describe('sub-controllers', () => { }); }); + describe('#buildProductListingSubControllers', () => { + let subControllers: ReturnType; + + beforeEach(() => { + subControllers = buildProductListingSubControllers(engine, { + responseIdSelector: mockResponseIdSelector, + isLoadingSelector: mockIsLoadingSelector, + numberOfProductsSelector: mockNumberOfProductsSelector, + errorSelector: mockErrorSelector, + pageSelector: mockPageSelector, + perPageSelector: mockPerPageSelector, + totalEntriesSelector: mockTotalEntriesSelector, + enrichSummary: mockAugmentSummary, + fetchProductsActionCreator: mockFetchProductsActionCreator, + fetchMoreProductsActionCreator: mockFetchMoreProductsActionCreator, + facetResponseSelector: mockFacetResponseSelector, + isFacetLoadingResponseSelector: mockIsFacetLoadingResponseSelector, + requestIdSelector: mockRequestIdSelector, + serializer: mockSerializer, + parametersDefinition: mockParametersDefinition as SchemaDefinition< + Required + >, + activeParametersSelector: mockActiveParametersSelector, + restoreActionCreator: mockRestoreActionCreator, + }); + }); + + it('exposes base sub-controllers', () => { + expect(subControllers).toHaveProperty('pagination'); + expect(subControllers).toHaveProperty('interactiveProduct'); + }); + + it('exposes search and listing sub-controllers', () => { + expect(subControllers).toHaveProperty('sort'); + expect(subControllers).toHaveProperty('facetGenerator'); + expect(subControllers).toHaveProperty('breadcrumbManager'); + expect(subControllers).toHaveProperty('urlManager'); + expect(subControllers).toHaveProperty('parameterManager'); + }); + + it('exposes interactiveSpotlightContent for product listing', () => { + expect(subControllers).toHaveProperty('interactiveSpotlightContent'); + }); + + it('#interactiveSpotlightContent builds interactive spotlight content controller', () => { + const buildCoreInteractiveSpotlightContentMock = vi.spyOn( + CoreInteractiveSpotlightContent, + 'buildCoreInteractiveSpotlightContent' + ); + + const props = { + options: { + spotlightContent: buildMockSpotlightContent({ + id: 'spotlight-1', + desktopImage: 'https://example.com/desktop.jpg', + position: 1, + }), + }, + }; + + const interactiveSpotlightContent = + subControllers.interactiveSpotlightContent(props); + + expect(interactiveSpotlightContent).toEqual( + buildCoreInteractiveSpotlightContentMock.mock.results[0].value + ); + expect(buildCoreInteractiveSpotlightContentMock).toHaveBeenCalledWith( + engine, + {...props, responseIdSelector: mockResponseIdSelector} + ); + }); + }); + describe('#buildSearchAndListingsSubControllers', () => { let subControllers: SearchAndListingSubControllers< Parameters, diff --git a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts index 293604ab9c0..d0664b3e89e 100644 --- a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts +++ b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts @@ -37,6 +37,11 @@ import { type InteractiveProduct, type InteractiveProductProps, } from '../interactive-product/headless-core-interactive-product.js'; +import { + buildCoreInteractiveSpotlightContent, + type InteractiveSpotlightContent, + type InteractiveSpotlightContentProps, +} from '../interactive-spotlight-content/headless-core-interactive-spotlight-content.js'; import { buildCorePagination, type Pagination, @@ -135,6 +140,21 @@ export interface SearchSubControllers didYouMean(): DidYouMean; } +export interface ProductListingSubControllers + extends SearchAndListingSubControllers< + ProductListingParameters, + ProductListingSummaryState + > { + /** + * Creates an `InteractiveSpotlightContent` sub-controller, for use when `enableResults` is set on the controller. + * @param props - The properties for the `InteractiveSpotlightContent` sub-controller. + * @returns An `InteractiveSpotlightContent` sub-controller. + */ + interactiveSpotlightContent( + props: InteractiveSpotlightContentProps + ): InteractiveSpotlightContent; +} + interface BaseSubControllerProps { responseIdSelector: (state: CommerceEngineState) => string; isLoadingSelector: (state: CommerceEngineState) => boolean; @@ -214,14 +234,20 @@ export function buildProductListingSubControllers( >, 'facetSearchType' > -): SearchAndListingSubControllers< - ProductListingParameters, - ProductListingSummaryState -> { - return buildSearchAndListingsSubControllers(engine, { - ...subControllerProps, - facetSearchType: 'LISTING', - }); +): ProductListingSubControllers { + const {responseIdSelector} = subControllerProps; + return { + ...buildSearchAndListingsSubControllers(engine, { + ...subControllerProps, + facetSearchType: 'LISTING', + }), + interactiveSpotlightContent(props: InteractiveSpotlightContentProps) { + return buildCoreInteractiveSpotlightContent(engine, { + ...props, + responseIdSelector, + }); + }, + }; } /** diff --git a/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.ts b/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.ts index 84ec67b2bca..59964291257 100644 --- a/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.ts +++ b/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.ts @@ -13,7 +13,6 @@ import { perPagePrincipalSelector, totalEntriesPrincipalSelector, } from '../../../features/commerce/pagination/pagination-selectors.js'; -import type {Parameters} from '../../../features/commerce/parameters/parameters-actions.js'; import {parametersDefinition} from '../../../features/commerce/parameters/parameters-schema.js'; import {activeParametersSelector} from '../../../features/commerce/parameters/parameters-selectors.js'; import {productListingSerializer} from '../../../features/commerce/parameters/parameters-serializer.js'; @@ -39,13 +38,12 @@ import { } from '../../controller/headless-controller.js'; import { buildProductListingSubControllers, - type SearchAndListingSubControllers, + type ProductListingSubControllers, } from '../core/sub-controller/headless-sub-controller.js'; import { facetResponseSelector, isFacetLoadingResponseSelector, } from './facets/headless-product-listing-facet-options.js'; -import type {ProductListingSummaryState} from './summary/headless-product-listing-summary.js'; /** * The `ProductListing` controller exposes a method for retrieving product listing content in a commerce interface. @@ -57,7 +55,7 @@ import type {ProductListingSummaryState} from './summary/headless-product-listin */ export interface ProductListing extends Controller, - SearchAndListingSubControllers { + ProductListingSubControllers { /** * Fetches the product listing. */ diff --git a/packages/headless/src/features/commerce/spotlight-content/spotlight-content-actions-loaders.ts b/packages/headless/src/features/commerce/spotlight-content/spotlight-content-actions-loaders.ts new file mode 100644 index 00000000000..b2cd67dbbb6 --- /dev/null +++ b/packages/headless/src/features/commerce/spotlight-content/spotlight-content-actions-loaders.ts @@ -0,0 +1,45 @@ +import type {AsyncThunkAction} from '@reduxjs/toolkit'; +import type {AsyncThunkCommerceOptions} from '../../../api/commerce/commerce-api-client.js'; +import type {CommerceEngineState} from '../../../app/commerce-engine/commerce-engine.js'; +import { + type SpotlightContentClickPayload, + spotlightContentClick, +} from './spotlight-content-actions.js'; + +export type {SpotlightContentClickPayload}; + +/** + * The spotlight content action creators. + * + * @group Actions + * @category SpotlightContent + */ +export interface SpotlightContentActionCreators { + /** + * Logs a click analytics event for a spotlight content item. + * + * @param payload - The action creator payload. + * @returns A dispatchable action. + */ + spotlightContentClick( + payload: SpotlightContentClickPayload + ): AsyncThunkAction< + void, + SpotlightContentClickPayload, + AsyncThunkCommerceOptions + >; +} + +/** + * Returns the possible spotlight content action creators. + * + * @returns An object holding the action creators. + * + * @group Actions + * @category SpotlightContent + */ +export function loadSpotlightContentActions(): SpotlightContentActionCreators { + return { + spotlightContentClick, + }; +} diff --git a/packages/headless/src/features/commerce/spotlight-content/spotlight-content-actions.ts b/packages/headless/src/features/commerce/spotlight-content/spotlight-content-actions.ts new file mode 100644 index 00000000000..af4132e94bb --- /dev/null +++ b/packages/headless/src/features/commerce/spotlight-content/spotlight-content-actions.ts @@ -0,0 +1,45 @@ +import type {ItemClick} from '@coveo/relay-event-types'; +import {createAsyncThunk} from '@reduxjs/toolkit'; +import type {AsyncThunkCommerceOptions} from '../../../api/commerce/commerce-api-client.js'; +import type {CommerceEngineState} from '../../../app/commerce-engine/commerce-engine.js'; + +export interface SpotlightContentClickPayload { + /** + * The unique identifier of the spotlight content. + */ + id: string; + /** + * The desktop image URL of the spotlight content. + */ + desktopImage: string; + /** + * The 1-based position of the spotlight content in the result set. + */ + position: number; + /** + * The response ID associated with the spotlight content. + */ + responseId: string; +} + +export const spotlightContentClick = createAsyncThunk< + void, + SpotlightContentClickPayload, + AsyncThunkCommerceOptions +>( + 'commerce/spotlight-content/click', + async (payload: SpotlightContentClickPayload, {extra}) => { + const {relay} = extra; + const relayPayload: ItemClick = { + responseId: payload.responseId, + position: payload.position, + itemMetadata: { + uniqueFieldName: 'id', + uniqueFieldValue: payload.id, + url: payload.desktopImage, + }, + }; + + relay.emit('itemClick', relayPayload); + } +); diff --git a/samples/headless/commerce-react/src/components/interactive-spotlight-content/interactive-spotlight-content.tsx b/samples/headless/commerce-react/src/components/interactive-spotlight-content/interactive-spotlight-content.tsx new file mode 100644 index 00000000000..450e1dab117 --- /dev/null +++ b/samples/headless/commerce-react/src/components/interactive-spotlight-content/interactive-spotlight-content.tsx @@ -0,0 +1,45 @@ +import type { + InteractiveSpotlightContent as HeadlessInteractiveSpotlightContent, + SpotlightContent, +} from '@coveo/headless/commerce'; + +interface IInteractiveSpotlightContentProps { + spotlightContent: SpotlightContent; + controller: HeadlessInteractiveSpotlightContent; +} + +export default function InteractiveSpotlightContent( + props: IInteractiveSpotlightContentProps +) { + const {spotlightContent, controller} = props; + + const clickSpotlightContent = () => { + controller.select(); + window.open(spotlightContent.clickUri, '_blank', 'noopener,noreferrer'); + }; + + return ( +
+ +
+ {spotlightContent.name +
+ {spotlightContent.description ? ( +
+

{spotlightContent.description}

+
+ ) : null} +
+
+ ); +} diff --git a/samples/headless/commerce-react/src/components/result-list/result-list.tsx b/samples/headless/commerce-react/src/components/result-list/result-list.tsx new file mode 100644 index 00000000000..83503ca45a3 --- /dev/null +++ b/samples/headless/commerce-react/src/components/result-list/result-list.tsx @@ -0,0 +1,71 @@ +import type { + Cart, + ChildProduct, + InteractiveProduct as HeadlessInteractiveProduct, + InteractiveSpotlightContent as HeadlessInteractiveSpotlightContent, + Result as HeadlessResult, + InteractiveProductProps, + InteractiveSpotlightContentProps, +} from '@coveo/headless/commerce'; +import {ResultType} from '@coveo/headless/commerce'; +import InteractiveProduct from '../interactive-product/interactive-product.js'; +import InteractiveSpotlightContent from '../interactive-spotlight-content/interactive-spotlight-content.js'; + +interface IResultListProps { + results: HeadlessResult[]; + productControllerBuilder: ( + props: InteractiveProductProps + ) => HeadlessInteractiveProduct; + spotlightContentControllerBuilder: ( + props: InteractiveSpotlightContentProps + ) => HeadlessInteractiveSpotlightContent; + cartController: Cart; + promoteChildToParent: (product: ChildProduct) => void; + navigate: (pathName: string) => void; +} + +export default function ResultList(props: IResultListProps) { + const { + results, + productControllerBuilder, + spotlightContentControllerBuilder, + cartController, + promoteChildToParent, + navigate, + } = props; + + if (results.length === 0) { + return null; + } + + return ( +
    + {results.map((result) => + result.resultType === ResultType.SPOTLIGHT ? ( + // keying on result.id should be fine so long as you do not have + // the same spotlight content appearing twice in one page. +
  • + +
  • + ) : ( +
  • + +
  • + ) + )} +
+ ); +} diff --git a/samples/headless/commerce-react/src/components/use-cases/search-and-listing-interface/search-and-listing-interface.css b/samples/headless/commerce-react/src/components/use-cases/listing-interface/listing-interface.css similarity index 100% rename from samples/headless/commerce-react/src/components/use-cases/search-and-listing-interface/search-and-listing-interface.css rename to samples/headless/commerce-react/src/components/use-cases/listing-interface/listing-interface.css diff --git a/samples/headless/commerce-react/src/components/use-cases/listing-interface/listing-interface.tsx b/samples/headless/commerce-react/src/components/use-cases/listing-interface/listing-interface.tsx new file mode 100644 index 00000000000..bed357fe416 --- /dev/null +++ b/samples/headless/commerce-react/src/components/use-cases/listing-interface/listing-interface.tsx @@ -0,0 +1,67 @@ +import type { + Cart, + ChildProduct, + ProductListing, +} from '@coveo/headless/commerce'; +import {useEffect, useState} from 'react'; +import BreadcrumbManager from '../../breadcrumb-manager/breadcrumb-manager.js'; +import FacetGenerator from '../../facets/facet-generator/facet-generator.js'; +import Pagination from '../../pagination/pagination.js'; +import ProductsPerPage from '../../products-per-page/products-per-page.js'; +import ResultList from '../../result-list/result-list.js'; +import Sort from '../../sort/sort.js'; +import Summary from '../../summary/summary.js'; +import './listing-interface.css'; +import ShowMore from '../../show-more/show-more.js'; + +interface IListingInterface { + listingController: ProductListing; + cartController: Cart; + navigate: (pathName: string) => void; +} + +export default function ListingInterface(props: IListingInterface) { + const {listingController, cartController, navigate} = props; + + const [listingState, setListingState] = useState(listingController.state); + + useEffect(() => { + listingController.subscribe(() => setListingState(listingController.state)); + }, [listingController]); + + const summaryController = listingController.summary(); + const paginationController = listingController.pagination(); + + return ( +
+
+ + +
+
+ +
+
+ + + listingController.promoteChildToParent(child) + } + navigate={navigate} + /> + + + +
+
+ ); +} diff --git a/samples/headless/commerce-react/src/components/use-cases/search-interface/search-interface.css b/samples/headless/commerce-react/src/components/use-cases/search-interface/search-interface.css new file mode 100644 index 00000000000..dcedc60166b --- /dev/null +++ b/samples/headless/commerce-react/src/components/use-cases/search-interface/search-interface.css @@ -0,0 +1,19 @@ +.column { + float: left; +} + +.small { + width: 25%; +} +.medium { + width: 50%; +} +.large { + width: 75%; +} + +.row:after { + content: ""; + display: table; + clear: both; +} diff --git a/samples/headless/commerce-react/src/components/use-cases/search-and-listing-interface/search-and-listing-interface.tsx b/samples/headless/commerce-react/src/components/use-cases/search-interface/search-interface.tsx similarity index 52% rename from samples/headless/commerce-react/src/components/use-cases/search-and-listing-interface/search-and-listing-interface.tsx rename to samples/headless/commerce-react/src/components/use-cases/search-interface/search-interface.tsx index 3904199a1a4..73950899fff 100644 --- a/samples/headless/commerce-react/src/components/use-cases/search-and-listing-interface/search-and-listing-interface.tsx +++ b/samples/headless/commerce-react/src/components/use-cases/search-interface/search-interface.tsx @@ -2,7 +2,6 @@ import type { Cart, ChildProduct, Search as HeadlessSearch, - ProductListing, } from '@coveo/headless/commerce'; import {useEffect, useState} from 'react'; import BreadcrumbManager from '../../breadcrumb-manager/breadcrumb-manager.js'; @@ -12,55 +11,45 @@ import ProductList from '../../product-list/product-list.js'; import ProductsPerPage from '../../products-per-page/products-per-page.js'; import Sort from '../../sort/sort.js'; import Summary from '../../summary/summary.js'; -import './search-and-listing-interface.css'; +import './search-interface.css'; import ShowMore from '../../show-more/show-more.js'; -interface ISearchAndListingInterface { - searchOrListingController: HeadlessSearch | ProductListing; +interface ISearchInterface { + searchController: HeadlessSearch; cartController: Cart; navigate: (pathName: string) => void; } -export default function SearchAndListingInterface( - props: ISearchAndListingInterface -) { - const {searchOrListingController, cartController, navigate} = props; +export default function SearchInterface(props: ISearchInterface) { + const {searchController, cartController, navigate} = props; - const [searchOrListingState, setSearchOrListingState] = useState( - searchOrListingController.state - ); + const [searchState, setSearchState] = useState(searchController.state); useEffect(() => { - searchOrListingController.subscribe(() => - setSearchOrListingState(searchOrListingController.state) - ); - }, [searchOrListingController]); + searchController.subscribe(() => setSearchState(searchController.state)); + }, [searchController]); - const summaryController = searchOrListingController.summary(); - const paginationController = searchOrListingController.pagination(); + const summaryController = searchController.summary(); + const paginationController = searchController.pagination(); return ( -
+
- +
- +
- + - searchOrListingController.promoteChildToParent(child) + searchController.promoteChildToParent(child) } navigate={navigate} > diff --git a/samples/headless/commerce-react/src/pages/product-listing-page.tsx b/samples/headless/commerce-react/src/pages/product-listing-page.tsx index 06abb33fc88..ce2ff34a575 100644 --- a/samples/headless/commerce-react/src/pages/product-listing-page.tsx +++ b/samples/headless/commerce-react/src/pages/product-listing-page.tsx @@ -7,7 +7,7 @@ import { } from '@coveo/headless/commerce'; import {useCallback, useEffect} from 'react'; import NotifyTrigger from '../components/triggers/notify-trigger.js'; -import SearchAndListingInterface from '../components/use-cases/search-and-listing-interface/search-and-listing-interface.js'; +import ListingInterface from '../components/use-cases/listing-interface/listing-interface.js'; interface IProductListingPageProps { engine: CommerceEngine; @@ -22,7 +22,9 @@ export default function ProductListingPage(props: IProductListingPageProps) { const {engine, cartController, contextController, url, pageName, navigate} = props; - const productListingController = buildProductListing(engine); + const productListingController = buildProductListing(engine, { + enableResults: true, + }); const bindUrlManager = useCallback(() => { const fragment = () => window.location.hash.slice(1); @@ -84,8 +86,8 @@ export default function ProductListingPage(props: IProductListingPageProps) {

{pageName}

- diff --git a/samples/headless/commerce-react/src/pages/search-page.tsx b/samples/headless/commerce-react/src/pages/search-page.tsx index 89fa778191b..62b66cfceec 100644 --- a/samples/headless/commerce-react/src/pages/search-page.tsx +++ b/samples/headless/commerce-react/src/pages/search-page.tsx @@ -14,7 +14,7 @@ import DidYouMean from '../components/did-you-mean/did-you-mean.js'; import SearchBox from '../components/search-box/search-box.js'; import NotifyTrigger from '../components/triggers/notify-trigger.js'; import QueryTrigger from '../components/triggers/query-trigger.js'; -import SearchAndListingInterface from '../components/use-cases/search-and-listing-interface/search-and-listing-interface.js'; +import SearchInterface from '../components/use-cases/search-interface/search-interface.js'; import {highlightOptions} from '../utils/highlight-options.js'; interface ISearchProps { @@ -111,8 +111,8 @@ export default function Search(props: ISearchProps) { -