Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/headless/src/commerce.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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(),
};
}
Loading
Loading