diff --git a/spec/src/modules/recommendations.js b/spec/src/modules/recommendations.js index c7d2182b..a70a8459 100644 --- a/spec/src/modules/recommendations.js +++ b/spec/src/modules/recommendations.js @@ -640,4 +640,255 @@ describe(`ConstructorIO - Recommendations${bundledDescriptionSuffix}`, () => { }); }); }); + + describe('getRecommendationPage', () => { + const mockApiKey = 'key_mock_page_api'; + const pageId = 'pdp_b2c'; + const itemId = 'product-123'; + const pageResultId = 'page-result-id'; + const pageResponse = () => ({ + request: { page_id: pageId, item_id: itemId, num_results: 10 }, + response: { + page_id: pageId, + display_name: 'PDP - B2C', + page_type: 'pdp', + pods: [ + { + pod_id: 'similar_items', + request: { item_id: itemId, num_results: 12 }, + response: { + results: [ + { data: { id: 'product-987' }, value: 'Red Running Shoe', strategy: { id: 'alternative_items' } }, + { data: { id: 'product-988' }, value: 'Blue Running Shoe', strategy: { id: 'alternative_items' } }, + ], + total_num_results: 2, + pod: { id: 'similar_items', display_name: 'Similar Items' }, + }, + result_id: 'similar-items-result-id', + }, + { + pod_id: 'complete_the_look', + request: { item_id: itemId, num_results: 8 }, + response: { + results: [], + total_num_results: 0, + pod: { id: 'complete_the_look', display_name: 'Complete the Look' }, + }, + result_id: 'complete-the-look-result-id', + }, + ], + }, + result_id: pageResultId, + }); + let fetchStub; + + beforeEach(() => { + fetchStub = sinon.stub().resolves({ ok: true, json: () => Promise.resolve(pageResponse()) }); + }); + + it('Should request the page endpoint with shared parameters', async () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + await recommendations.getRecommendationPage(pageId, { + itemIds: itemId, + numResults: 10, + section: 'Products', + filters: { in_stock: 'true' }, + filterMatchTypes: { color: 'any' }, + }); + + const requestedUrl = fetchStub.lastCall.args[0]; + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchStub); + + expect(requestedUrl).to.match(/\/recommendations\/v1\/pages\/pdp_b2c\?/); + expect(requestedUrlParams).to.have.property('key').to.equal(mockApiKey); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + expect(requestedUrlParams).to.have.property('item_id').to.equal(itemId); + expect(requestedUrlParams).to.have.property('num_results').to.equal('10'); + expect(requestedUrlParams).to.have.property('section').to.equal('Products'); + expect(requestedUrlParams.filters).to.deep.equal({ in_stock: 'true' }); + expect(requestedUrlParams.filter_match_types).to.deep.equal({ color: 'any' }); + expect(requestedUrlParams).to.not.have.property('pod_overrides'); + }); + + it('Should encode podOverrides in bracket notation with the same wire format as shared parameters', async () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + const preFilterExpression = { or: [{ name: 'brand', value: 'acme' }] }; + const variationsMap = { group_by: [{ name: 'color', field: 'data.color' }], values: {}, dtype: 'array' }; + + await recommendations.getRecommendationPage(pageId, { + itemIds: itemId, + numResults: 10, + podOverrides: { + similar_items: { numResults: 0 }, + complete_the_look: { + numResults: 8, + filters: { in_stock: 'true', color: ['red', 'blue'] }, + filterMatchTypes: { color: 'all' }, + preFilterExpression, + variationsMap, + fmtOptions: { groups_max_depth: 2 }, + hiddenFields: ['inventory', 'margin'], + }, + }, + }); + + const requestedUrl = decodeURIComponent(fetchStub.lastCall.args[0]); + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchStub); + const completeTheLook = requestedUrlParams.pod_overrides.complete_the_look; + + expect(requestedUrl).to.include('pod_overrides[similar_items][num_results]=0'); + expect(requestedUrl).to.include('pod_overrides[complete_the_look][filters][color]=red&pod_overrides[complete_the_look][filters][color]=blue'); + expect(requestedUrlParams.num_results).to.equal('10'); + expect(requestedUrlParams.pod_overrides.similar_items).to.deep.equal({ num_results: '0' }); + expect(completeTheLook.num_results).to.equal('8'); + expect(completeTheLook.filters).to.deep.equal({ in_stock: 'true', color: ['red', 'blue'] }); + expect(completeTheLook.filter_match_types).to.deep.equal({ color: 'all' }); + expect(JSON.parse(completeTheLook.pre_filter_expression)).to.deep.equal(preFilterExpression); + expect(JSON.parse(completeTheLook.variations_map)).to.deep.equal(variationsMap); + expect(completeTheLook.fmt_options).to.deep.equal({ groups_max_depth: '2', hidden_fields: ['inventory', 'margin'] }); + }); + + it('Should not mutate a fmtOptions object shared between page-wide parameters and an override', async () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + const fmtOptions = { groups_max_depth: 2 }; + + await recommendations.getRecommendationPage(pageId, { + fmtOptions, + podOverrides: { similar_items: { fmtOptions, hiddenFields: ['inventory'] } }, + }); + + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchStub); + + expect(fmtOptions).to.deep.equal({ groups_max_depth: 2 }); + expect(requestedUrlParams.fmt_options).to.deep.equal({ groups_max_depth: '2' }); + }); + + it('Should stamp each pod\'s own result_id onto its results, not the page result_id', async () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + const res = await recommendations.getRecommendationPage(pageId, { itemIds: itemId }); + const [similarItems, completeTheLook] = res.response.pods; + + expect(res.result_id).to.equal(pageResultId); + similarItems.response.results.forEach((result) => { + expect(result.result_id).to.equal('similar-items-result-id'); + }); + expect(completeTheLook.response.results).to.deep.equal([]); + }); + + it('Should emit a getRecommendations.completed event per pod and a page completed event', async () => { + const { recommendations } = new ConstructorIO({ + apiKey: mockApiKey, + fetch: fetchStub, + eventDispatcher: { waitForBeacon: false }, + }); + const podEvents = []; + const pageEvents = []; + + // `CustomEvent` in Node context not containing `detail`, so checking arguments instead + const customEventSpy = sinon.spy(window, 'CustomEvent'); + + await recommendations.getRecommendationPage(pageId, { itemIds: itemId }); + + customEventSpy.getCalls().forEach((call) => { + const [eventName, { detail }] = call.args; + + if (eventName === 'cio.client.recommendations.getRecommendations.completed') { + podEvents.push(detail); + } else if (eventName === 'cio.client.recommendations.getRecommendationPage.completed') { + pageEvents.push(detail); + } + }); + customEventSpy.restore(); + + expect(podEvents).to.have.length(2); + expect(podEvents[0].request.pod_id).to.equal('similar_items'); + expect(podEvents[0].request.item_id).to.equal(itemId); + expect(podEvents[0].result_id).to.equal('similar-items-result-id'); + expect(podEvents[0].response.pod.id).to.equal('similar_items'); + expect(podEvents[0].response.results[0].result_id).to.equal('similar-items-result-id'); + expect(podEvents[1].request.pod_id).to.equal('complete_the_look'); + expect(podEvents[1].result_id).to.equal('complete-the-look-result-id'); + expect(podEvents[1].response.results).to.deep.equal([]); + expect(pageEvents).to.have.length(1); + expect(pageEvents[0].result_id).to.equal(pageResultId); + }); + + it('Should leave each pod\'s request without pod_id in the returned response', async () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + const res = await recommendations.getRecommendationPage(pageId, { itemIds: itemId }); + + expect(res.response.pods[0].request).to.not.have.property('pod_id'); + }); + + it('Should be rejected when the response has no pods', () => { + fetchStub = sinon.stub().resolves({ ok: true, json: () => Promise.resolve({ response: {} }) }); + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + return expect(recommendations.getRecommendationPage(pageId)).to.eventually.be.rejectedWith('getRecommendationPage response data is malformed'); + }); + + it('Should be rejected when pageId is not provided', () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + return expect(recommendations.getRecommendationPage(null, { itemIds: itemId })).to.eventually.be.rejectedWith('pageId is a required parameter of type string'); + }); + + it('Should be rejected when podOverrides contains a page-wide parameter', () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + return expect(recommendations.getRecommendationPage(pageId, { + podOverrides: { similar_items: { itemIds: 'other' } }, + })).to.eventually.be.rejectedWith('podOverrides.similar_items contains parameters that cannot be overridden per pod: itemIds'); + }); + + it('Should be rejected when variationId is provided without itemIds', () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + return expect(recommendations.getRecommendationPage(pageId, { variationId: 'v1' })).to.eventually.be.rejectedWith('itemIds is a required parameter for variationId'); + }); + }); + + // The page endpoint is not yet enabled on the test index, and the test index has no + // page configured (`/recommendations/v1/pages/pdp_b2c` returns 404 there) + describe.skip('getRecommendationPage - live', () => { + const pageId = 'pdp_b2c'; + const itemId = 'power_drill'; + + it('Should return a response with each pod\'s result_id stamped on its results', async () => { + const { recommendations } = new ConstructorIO({ apiKey: testApiKey, fetch: fetchSpy }); + + const res = await recommendations.getRecommendationPage(pageId, { itemIds: itemId }); + + expect(res).to.have.property('result_id').to.be.a('string'); + expect(res.response.pods).to.be.an('array').that.is.not.empty; + res.response.pods.forEach((pod) => { + expect(pod).to.have.property('pod_id').to.be.a('string'); + expect(pod).to.have.property('result_id').to.be.a('string').that.does.not.equal(res.result_id); + expect(pod.response.pod.id).to.equal(pod.pod_id); + pod.response.results.forEach((result) => { + expect(result.result_id).to.equal(pod.result_id); + }); + }); + }); + + it('Should apply a numResults override to one pod', async () => { + const { recommendations } = new ConstructorIO({ apiKey: testApiKey, fetch: fetchSpy }); + const first = await recommendations.getRecommendationPage(pageId, { itemIds: itemId }); + const overriddenPodId = first.response.pods[0].pod_id; + + const res = await recommendations.getRecommendationPage(pageId, { + itemIds: itemId, + podOverrides: { [overriddenPodId]: { numResults: 1 } }, + }); + + expect(res.response.pods[0].request.num_results).to.equal(1); + expect(res.response.pods[0].response.results.length).to.be.at.most(1); + }); + }); }); diff --git a/src/modules/recommendations.js b/src/modules/recommendations.js index d7b01cae..187b45d3 100644 --- a/src/modules/recommendations.js +++ b/src/modules/recommendations.js @@ -2,21 +2,26 @@ const EventDispatcher = require('../utils/event-dispatcher'); const helpers = require('../utils/helpers'); -// Create URL from supplied parameters -// eslint-disable-next-line complexity -function createRecommendationsUrl(podId, parameters, options) { - const { apiKey, version, serviceUrl, sessionId, userId, clientId, segments, testCells } = options; - let queryParams = { c: version }; +// Parameters that a page request accepts per pod in `podOverrides` +const PAGE_OVERRIDABLE_PARAMETERS = [ + 'numResults', + 'filters', + 'filterMatchTypes', + 'preFilterExpression', + 'fmtOptions', + 'hiddenFields', + 'variationsMap', +]; + +// Create base query parameters shared by pod and page requests +function createBaseQueryParams(options) { + const { apiKey, version, sessionId, userId, clientId, segments, testCells } = options; + const queryParams = { c: version }; queryParams.key = apiKey; queryParams.i = clientId; queryParams.s = sessionId; - // Validate pod identifier is provided - if (!podId || typeof podId !== 'string') { - throw new Error('podId is a required parameter of type string'); - } - // Pull user segments from options if (segments && segments.length) { queryParams.us = segments; @@ -34,6 +39,21 @@ function createRecommendationsUrl(podId, parameters, options) { }); } + return queryParams; +} + +// Create URL from supplied parameters +// eslint-disable-next-line complexity +function createRecommendationsUrl(podId, parameters, options) { + const { serviceUrl } = options; + + // Validate pod identifier is provided + if (!podId || typeof podId !== 'string') { + throw new Error('podId is a required parameter of type string'); + } + + let queryParams = createBaseQueryParams(options); + if (parameters) { const { numResults, @@ -119,6 +139,129 @@ function createRecommendationsUrl(podId, parameters, options) { return `${serviceUrl}/recommendations/v1/pods/${helpers.encodeURIComponentRFC3986(helpers.trimNonBreakingSpaces(podId))}?${queryString}`; } +// Map parameters that can be set per pod on a page request to their query parameter form +// - uses the same wire format as `createRecommendationsUrl` +// eslint-disable-next-line complexity +function createPageOverridableParams(parameters) { + const { + numResults, + filters, + filterMatchTypes, + preFilterExpression, + fmtOptions, + hiddenFields, + variationsMap, + } = parameters; + const params = {}; + + if (!helpers.isNil(numResults)) { + params.num_results = numResults; + } + + if (filters) { + params.filters = filters; + } + + if (filterMatchTypes) { + params.filter_match_types = filterMatchTypes; + } + + if (fmtOptions) { + params.fmt_options = { ...fmtOptions }; + } + + if (hiddenFields) { + params.fmt_options = { ...params.fmt_options, hidden_fields: hiddenFields }; + } + + if (variationsMap) { + params.variations_map = JSON.stringify(variationsMap); + } + + if (preFilterExpression) { + params.pre_filter_expression = JSON.stringify(preFilterExpression); + } + + return params; +} + +// Create page URL from supplied parameters +// eslint-disable-next-line complexity +function createRecommendationPageUrl(pageId, parameters, options) { + const { serviceUrl } = options; + + // Validate page identifier is provided + if (!pageId || typeof pageId !== 'string') { + throw new Error('pageId is a required parameter of type string'); + } + + let queryParams = createBaseQueryParams(options); + + if (parameters) { + const { itemIds, variationId, section, term, podOverrides } = parameters; + + // Pull item ids from parameters + if (itemIds) { + queryParams.item_id = itemIds; + } + + if (variationId) { + if (!itemIds || (typeof itemIds === 'string' && !itemIds.trim())) { + throw new Error('itemIds is a required parameter for variationId'); + } + + if (Array.isArray(itemIds) && !itemIds.length) { + throw new Error('At least one itemId is a required parameter for variationId'); + } + + queryParams.variation_id = variationId; + } + + // Pull section from parameters + if (section) { + queryParams.section = section; + } + + // Pull term from parameters + if (term) { + queryParams.term = term; + } + + queryParams = { ...queryParams, ...createPageOverridableParams(parameters) }; + + // Pull per-pod overrides from parameters + if (podOverrides) { + if (typeof podOverrides !== 'object' || Array.isArray(podOverrides)) { + throw new Error('podOverrides must be an object keyed by pod id'); + } + + const podOverrideParams = {}; + + Object.keys(podOverrides).forEach((podId) => { + const override = podOverrides[podId] || {}; + const unsupported = Object.keys(override) + .filter((name) => !PAGE_OVERRIDABLE_PARAMETERS.includes(name)); + + if (unsupported.length) { + throw new Error(`podOverrides.${podId} contains parameters that cannot be overridden per pod: ${unsupported.join(', ')}. Supported: ${PAGE_OVERRIDABLE_PARAMETERS.join(', ')}`); + } + + podOverrideParams[podId] = createPageOverridableParams(override); + }); + + queryParams.pod_overrides = podOverrideParams; + } + } + + // eslint-disable-next-line no-underscore-dangle + queryParams._dt = Date.now(); + queryParams = helpers.cleanParams(queryParams); + + const queryString = helpers.stringify(queryParams); + + return `${serviceUrl}/recommendations/v1/pages/${helpers.encodeURIComponentRFC3986(helpers.trimNonBreakingSpaces(pageId))}?${queryString}`; +} + /** * Interface to recommendations related API calls * @@ -202,6 +345,109 @@ class Recommendations { throw new Error('getRecommendations response data is malformed'); }); } + + /** + * Get recommendations for every pod configured on a page + * + * Beta: available to a small group of users and may change. Contact your Customer Success + * Manager to request access. + * + * @function getRecommendationPage + * @description Retrieve recommendation results for every pod on a page from Constructor.io API. + * Each pod's `result_id` is appended to that pod's results, and a + * `recommendations.getRecommendations.completed` event is dispatched per pod so pod-level + * tracking works as it does for `getRecommendations`. The top-level `result_id` identifies the + * page request and must not be used for tracking. + * @param {string} pageId - Page identifier + * @param {object} [parameters] - Additional parameters to refine results, applied to every pod + * @param {string|array} [parameters.itemIds] - Item ID(s) to retrieve recommendations for (strategy specific). + * Required for variationId + * @param {string} [parameters.variationId] - Variation ID to retrieve recommendations for (strategy specific) + * @param {number} [parameters.numResults] - The number of results to return per pod + * @param {string} [parameters.section] - The section to return results from + * @param {string} [parameters.term] - The term to use to refine results (strategy specific) + * @param {object} [parameters.filters] - Key / value mapping of filters used to refine results + * @param {object} [parameters.filterMatchTypes] - Whether results must match `all`, `any` or `none` + * of each filter's values + * @param {object} [parameters.variationsMap] - The variations map object to aggregate variations. Please refer to https://docs.constructor.com/reference/shared-variations-mapping for details + * @param {object} [parameters.preFilterExpression] - Faceting expression to scope search results. Please refer to https://docs.constructor.com/reference/configuration-collections + * @param {object} [parameters.fmtOptions] - An object containing options to format different aspects of the response. Please refer to https://docs.constructor.com/reference/v1-recommendations-get-pod-results for details + * @param {string[]} [parameters.hiddenFields] - Hidden metadata fields to return + * @param {object} [parameters.podOverrides] - Per-pod values keyed by pod id. Each value may contain + * `numResults`, `filters`, `filterMatchTypes`, `preFilterExpression`, `fmtOptions`, `hiddenFields` and + * `variationsMap`, and replaces (does not merge with) the page-wide value for that pod. + * `hiddenFields` is sent inside `fmt_options`, so an override with either `fmtOptions` or + * `hiddenFields` replaces all page-wide `fmtOptions` and `hiddenFields` for that pod + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {Promise} + * @see https://docs.constructor.com/reference/v1-recommendations-get-page-results + * @example + * constructorio.recommendations.getRecommendationPage('pdp_b2c', { + * itemIds: 'product-123', + * numResults: 10, + * podOverrides: { + * complete_the_look: { + * numResults: 8, + * filters: { category: 'Apparel' }, + * }, + * }, + * }); + */ + getRecommendationPage(pageId, parameters, networkParameters = {}) { + let requestUrl; + const { fetch } = this.options; + let signal; + + if (typeof AbortController === 'function') { + const controller = new AbortController(); + + signal = controller && controller.signal; + + // Handle network timeout if specified + helpers.applyNetworkTimeout(this.options, networkParameters, controller); + } + + try { + requestUrl = createRecommendationPageUrl(pageId, parameters, this.options); + } catch (e) { + return Promise.reject(e); + } + + return fetch(requestUrl, { signal }) + .then(helpers.convertResponseToJson) + .then((json) => { + if (json.response && Array.isArray(json.response.pods)) { + json.response.pods.forEach((pod) => { + if (!pod || !pod.response || !Array.isArray(pod.response.results)) { + return; + } + + // Append the pod's own `result_id` (not the page's) to each of its results + if (pod.result_id) { + pod.response.results.forEach((result) => { + // eslint-disable-next-line no-param-reassign + result.result_id = pod.result_id; + }); + } + + // Dispatch the pod in the shape `getRecommendations` returns, so listeners + // - keyed on `request.pod_id` handle each pod as a single-pod response + this.eventDispatcher.queue('recommendations.getRecommendations.completed', { + request: { ...pod.request, pod_id: pod.pod_id }, + response: pod.response, + result_id: pod.result_id, + }); + }); + + this.eventDispatcher.queue('recommendations.getRecommendationPage.completed', json); + + return json; + } + + throw new Error('getRecommendationPage response data is malformed'); + }); + } } module.exports = Recommendations; diff --git a/src/types/recommendations.d.ts b/src/types/recommendations.d.ts index 9d074a80..831eda10 100644 --- a/src/types/recommendations.d.ts +++ b/src/types/recommendations.d.ts @@ -16,6 +16,25 @@ export interface RecommendationsParameters { fmtOptions?: FmtOptions; } +/** Parameters that can be set per pod on a page request. Each replaces the page-wide value. */ +export interface RecommendationPagePodOverride { + numResults?: number; + filters?: Record; + filterMatchTypes?: Record; + preFilterExpression?: FilterExpression; + fmtOptions?: FmtOptions; + hiddenFields?: string[]; + variationsMap?: VariationsMap; +} + +export interface RecommendationPageParameters extends RecommendationPagePodOverride { + itemIds?: string | string[]; + variationId?: string; + section?: string; + term?: string; + podOverrides?: Record; +} + declare class Recommendations { constructor(options: ConstructorClientOptions); @@ -28,6 +47,12 @@ declare class Recommendations { parameters?: RecommendationsParameters, networkParameters?: NetworkParameters ): Promise; + + getRecommendationPage( + pageId: string, + parameters?: RecommendationPageParameters, + networkParameters?: NetworkParameters + ): Promise; } /** ********* @@ -71,3 +96,29 @@ export interface RecommendationsResultType extends Record { [key: string]: any; }; } + +/** ********* + * Recommendation page results returned from server + ********** */ +export interface RecommendationPageResponse extends Record { + request: Record; + response: RecommendationPageResponseType; + /** Identifies the page request. Not a tracking id: use each pod's `result_id`. */ + result_id: string; +} + +export interface RecommendationPageResponseType extends Record { + page_id: string; + display_name: string; + page_type: string; + pods: RecommendationPagePod[]; +} + +export interface RecommendationPagePod extends Record { + pod_id: string; + /** The pod's effective request: page-wide parameters with this pod's overrides applied */ + request: Partial; + response: Partial; + /** Send this with the pod's recommendation view and click events */ + result_id: string; +} diff --git a/src/types/tests/recommedations.test-d.ts b/src/types/tests/recommedations.test-d.ts index 30b940b6..4551d1ad 100644 --- a/src/types/tests/recommedations.test-d.ts +++ b/src/types/tests/recommedations.test-d.ts @@ -1,5 +1,9 @@ import { expectAssignable } from 'tsd'; -import { RecommendationsResponse } from '../recommendations.d'; +import { + RecommendationsResponse, + RecommendationPageResponse, + RecommendationPageParameters, +} from '../recommendations.d'; expectAssignable({ request: { @@ -43,3 +47,52 @@ expectAssignable({ }, result_id: '618c2aa1-b851-451f-b228-61d02bf7e3c5', }); + +expectAssignable({ + itemIds: 'product-123', + numResults: 10, + filters: { in_stock: 'true' }, + podOverrides: { + similar_items: { numResults: 12 }, + complete_the_look: { + numResults: 8, + filters: { in_stock: 'true', category: 'Apparel' }, + filterMatchTypes: { category: 'any' }, + preFilterExpression: { name: 'brand', value: 'acme' }, + }, + }, +}); + +expectAssignable({ + request: { + page_id: 'pdp_b2c', + item_id: 'product-123', + num_results: 10, + }, + response: { + page_id: 'pdp_b2c', + display_name: 'PDP - B2C', + page_type: 'pdp', + pods: [ + { + pod_id: 'similar_items', + request: { item_id: 'product-123', num_results: 12 }, + response: { + results: [ + { + data: { id: 'product-987', variation_id: 'product-987-red' }, + value: 'Red Running Shoe', + is_slotted: false, + labels: {}, + strategy: { id: 'alternative_items' }, + }, + ], + total_num_results: 1, + pod: { id: 'similar_items', display_name: 'Similar Items' }, + }, + result_id: 'a1b2c3d4-0000-0000-0000-000000000001', + }, + ], + }, + result_id: 'a1b2c3d4-0000-0000-0000-0000000000ff', +});