diff --git a/docs/INTEGRATION_LEGACY_RECOMMENDATIONS.md b/docs/INTEGRATION_LEGACY_RECOMMENDATIONS.md new file mode 100644 index 000000000..a546d171f --- /dev/null +++ b/docs/INTEGRATION_LEGACY_RECOMMENDATIONS.md @@ -0,0 +1,149 @@ +## Recommendations Integration (Legacy) + +For integrations using Snap `v0.60.0` and newer, please reference the updated [`integration docs`](https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_RECOMMENDATIONS.md). + + +It is recommended to utilize the [`RecommendationInstantiator`](https://github.com/searchspring/snap/blob/main/packages/snap-preact/src/Instantiators/README.md) for integration of product recommendations. This method allows recommendations to be placed anywhere on the site with a single script block (requires the `bundle.js` script also). + +```html + +``` + +The `RecommendationInstantiator` will look for these elements on the page and attempt to inject components based on the `profile` specified. In the example above, the profile specified is the `recently-viewed` profile, and would typically be setup to display the last products viewed by the shopper. These profiles must be setup in the Searchspring Management Console (SMC) and have associated Snap templates selected. + + +## Recommendation Context Variables +Context variables may be applied to individual recommendation profiles similar to how they are done on the integration script tag. Variables here may be required depending on the profile type utilized, and can be used to alter the results displayed by our recommendations. + +| Option | Value | Page | Description | +|---|---|:---:|---| +| products | array of SKU strings | product detail page | SKU value(s) to identify the current product(s) being viewed | +| cart | array (or function that returns an array) of current cart skus | all | optional method of setting cart contents | +| options.siteId | global siteId overwrite | all | optional global siteId overwrite | +| options.categories | array of category path strings | all | optional category identifiers used in category trending recommendation profiles | +| options.brands | array of brand strings | all | optional brand identifiers used in brand trending recommendation profiles | +| options.branch | template branch overwrite | all | optional branch overwrite for recommendations template (advanced usage) | +| options.filters | array of filters | all | optional recommendation filters | +| options.query | string | all | query to search | +| options.realtime | boolean | all | optional update recommendations if cart contents change (requires [cart attribute tracking](https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_TRACKING.md)) | +| options.blockedItems | array of strings | all | SKU values to identify which products to exclude from the response | +| options.batched | boolean (default: `true`)| all | only applies to recommendation context, optional disable profile from being batched in a single request, can also be set globally [via config](https://github.com/searchspring/snap/tree/main/packages/snap-controller/src/Recommendation) | +| options.order | number | all | optional order number for recommendation params to be added to the batched request. Profiles that do not specify an order will be placed at the end, in the occurrence they appear in the DOM. +| options.limit | number (default: 20, max: 20) | all | optional maximum number of results to display, can also be set globally [via config globals](https://github.com/searchspring/snap/tree/main/packages/snap-controller/src/Recommendation) | +| shopper.id | logged in user unique identifier | all | required for personalization functionallity if not provided to the bundle (global) context | + +## Batching and Ordering +By default, recommendation profile results are fetched in the same API request (batch), this is done in an effort to prevent the display of duplicate products across multiple profiles. The order of the profiles in the DOM determines the priority of results for de-duplication (best recommendations). If you wish to change the order, an `order` value can be provided (lowest value has highest priority). For some profiles (like product bundles) it is important that they receive the best suggested products prior to de-duplication, for these, the `order` would be set manually so that de-duplication does not occur. + +In most cases batching is the best practice, however for profiles like a mini cart (side cart) de-duplication may not be desired. Batching can be turned off per profile with a `batched: false` value. + +The example below shows how to manually specify the order and batching of specific profiles. + +```html + + + + + + + +``` + +## Additional Examples + +The examples below assume that the `similar` profile has been setup in the Searchspring Management Console (SMC), and that a Snap `bundle.js` script exists on the page and has been configured with a `RecommendationInstantiator`. + +A typical "similar" profile that would display products similar to the product passed in via the `product` context variable. + +```html + +``` + +If tracking scripts are not in place, "also bought" profiles may require the cart contents to be provided. + +```html + +``` + +If the shopper identifier is not beeing captured by the `bundle.js` context, it must be provided for proper personalization. + +```html + +``` + +Having multiple scripts batched using the order context variable + +```html + + + +``` + +### Filters +The example shown below will filter the recommendations for products matching color: blue, & red, and price range 0 - 20. + +```html + +``` \ No newline at end of file diff --git a/docs/INTEGRATION_RECOMMENDATIONS.md b/docs/INTEGRATION_RECOMMENDATIONS.md index 7745ff7bc..b2ead6db2 100644 --- a/docs/INTEGRATION_RECOMMENDATIONS.md +++ b/docs/INTEGRATION_RECOMMENDATIONS.md @@ -1,68 +1,106 @@ ## Recommendations Integration -It is recommended to utilize the [`RecommendationInstantiator`](https://github.com/searchspring/snap/blob/main/packages/snap-preact/src/Instantiators/README.md) for integration of product recommendations. This method allows recommendations to be placed anywhere on the site with a single script block (requires the `bundle.js` script also). + +Changes to the recommendation integration scripts were made in Snap `v0.60.0`. Legacy Recommmendation Integrations docs can still be found [`here`](https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_LEGACY_RECOMMENDATIONS.md) + +It is recommended to utilize the [`RecommendationInstantiator`](https://github.com/searchspring/snap/blob/main/packages/snap-preact/src/Instantiators/README.md) for integration of product recommendations. This method allows recommendations to be placed anywhere on the page with a single script block (requires the `bundle.js` script also). ```html - ``` -The `RecommendationInstantiator` will look for these elements on the page and attempt to inject components based on the `profile` specified. In the example above, the profile specified is the `recently-viewed` profile, and would typically be setup to display the last products viewed by the shopper. These profiles must be setup in the Searchspring Management Console (SMC) and have associated Snap templates selected. +The `RecommendationInstantiator` will look for these elements on the page and attempt to inject components based on the `profiles` specified. In the example above, the profile specified is the `recently-viewed` profile, and is set to render inside the target `.ss__recs__recently-viewed`, this profile would typically be setup to display the last products viewed by the shopper. These profiles must be setup in the Searchspring Management Console (SMC) and have associated Snap templates selected. ## Recommendation Context Variables -Context variables may be applied to individual recommendation profiles similar to how they are done on the integration script tag. Variables here may be required depending on the profile type utilized, and can be used to alter the results displayed by our recommendations. - -| Option | Value | Page | Description | -|---|---|:---:|---| -| products | array of SKU strings | product detail page | SKU value(s) to identify the current product(s) being viewed | -| cart | array (or function that returns an array) of current cart skus | all | optional method of setting cart contents | -| options.siteId | global siteId overwrite | all | optional global siteId overwrite | -| options.categories | array of category path strings | all | optional category identifiers used in category trending recommendation profiles | -| options.brands | array of brand strings | all | optional brand identifiers used in brand trending recommendation profiles | -| options.branch | template branch overwrite | all | optional branch overwrite for recommendations template (advanced usage) | -| options.filters | array of filters | all | optional recommendation filters | -| options.realtime | boolean | all | optional update recommendations if cart contents change (requires [cart attribute tracking](https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_TRACKING.md)) | -| options.blockedItems | array of strings | all | SKU values to identify which products to exclude from the response | -| options.batched | boolean (default: `true`)| all | only applies to recommendation context, optional disable profile from being batched in a single request, can also be set globally [via config](https://github.com/searchspring/snap/tree/main/packages/snap-controller/src/Recommendation) | -| options.order | number | all | optional order number for recommendation params to be added to the batched request. Profiles that do not specify an order will be placed at the end, in the occurrence they appear in the DOM. -| options.limit | number (default: 20, max: 20) | all | optional maximum number of results to display, can also be set globally [via config globals](https://github.com/searchspring/snap/tree/main/packages/snap-controller/src/Recommendation) | -| shopper.id | logged in user unique identifier | all | required for personalization functionallity if not provided to the bundle (global) context | +Context variables are applied to individual recommendation profiles similar to how they are done on the integration script tag. Variables here may be required depending on the profile placement, and can be used to alter the results displayed by our recommendations. + +### Globals Variables +| Option | Value | Placement | Description | Required +|---|---|:---:|---|:---:| +| products | array of SKU strings | product detail page | SKU value(s) to identify the current product(s) being viewed | ✔️ | +| blockedItems | array of strings | all | SKU values to identify which products to exclude from the response | | +| cart | array (or function that returns an array) of current cart skus | all | optional method of setting cart contents | | +| shopper.id | logged in user unique identifier | all | required for personalization functionallity if not provided to the bundle (global) context | | + + +### Profile Specific Variables +| Option | Value | Placement | Description | Required +|---|---|:---:|---|:---:| +| profile | string | all | profile name to use | ✔️ | +| target | string | all | CSS selector to render component inside | ✔️ | +| options.siteId | global siteId overwrite | all | optional global siteId overwrite | | +| options.categories | array of category path strings | all | optional category identifiers used in category trending recommendation profiles | | +| options.brands | array of brand strings | all | optional brand identifiers used in brand trending recommendation profiles | | +| options.branch | template branch overwrite | all | optional branch overwrite for recommendations template (advanced usage) | | +| options.dedupe | boolean (default: `true`) | all | dedupe products across all profiles in the batch | | +| options.query | string | dynamic custom | query to search | | +| options.filters | array of filters | all | optional recommendation filters | | +| options.realtime | boolean | all | optional update recommendations if cart contents change (requires [cart attribute tracking](https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_TRACKING.md)) | | +| options.limit | number (default: 20, max: 20) | all | optional maximum number of results to display, can also be set globally [via config globals](https://github.com/searchspring/snap/tree/main/packages/snap-controller/src/Recommendation) | | + ## Batching and Ordering -By default, recommendation profile results are fetched in the same API request (batch), this is done in an effort to prevent the display of duplicate products across multiple profiles. The order of the profiles in the DOM determines the priority of results for de-duplication (best recommendations). If you wish to change the order, an `order` value can be provided (lowest value has highest priority). For some profiles (like product bundles) it is important that they receive the best suggested products prior to de-duplication, for these, the `order` would be set manually so that de-duplication does not occur. +Each "searchspring/recommendations" script block groups multiple recommendation profiles into a single API request, known as a batch. By default, the script tag fetches recommendations for all profiles with a matching target in one batched request. The order of profiles in the array determines their priority within the batch. -In most cases batching is the best practice, however for profiles like a mini cart (side cart) de-duplication may not be desired. Batching can be turned off per profile with a `batched: false` value. +While batching all profiles together is generally the most efficient approach, there may be cases where separate batching is preferred. For instance, recommendations for a mini cart (side cart) might not require de-duplication with other recommendations. You can disable de-duplication for a specific profile by setting `dedupe: false` in its options, or create a separate batch by using an additional script tag. -The example below shows how to manually specify the order and batching of specific profiles. +## Deduping -```html - +Deduping is a process that prevents the same product from appearing in multiple recommendation profiles within a single batch. This is particularly useful when you have several recommendation profiles on a page and want to ensure a diverse range of products is shown to the user. - +Here's how deduping works: - +1. By default, deduping is enabled for all profiles in a batch (`options.dedupe: true`). +2. The order of profiles in the array determines their priority for deduping. +3. When a product is returned for a higher-priority profile, it becomes unavailable for lower-priority profiles in the same batch. + +For example, if you have three profiles in this order: "Customers Also Bought", "Similar Products", and "You May Also Like", and a product is returned for "Customers Also Bought", it won't appear in "Similar Products" or "You May Also Like". + +You can disable deduping for specific profiles by setting `options.dedupe: false`. This is useful for profiles where you want to ensure certain products always appear, regardless of their presence in other recommendations. + +Here's an example that demonstrates deduping: - ``` @@ -70,75 +108,85 @@ The example below shows how to manually specify the order and batching of specif The examples below assume that the `similar` profile has been setup in the Searchspring Management Console (SMC), and that a Snap `bundle.js` script exists on the page and has been configured with a `RecommendationInstantiator`. -A typical "similar" profile that would display products similar to the product passed in via the `product` context variable. - -```html - -``` - -If tracking scripts are not in place, "also bought" profiles may require the cart contents to be provided. +A typical "similar" profile that would display products similar to the product passed in via the `products` global context variable. ```html - ``` -If the shopper identifier is not beeing captured by the `bundle.js` context, it must be provided for proper personalization. +If tracking scripts are not in place, "crosssell" profiles may require the cart contents to be provided. ```html - ``` -Having multiple scripts batched using the order context variable +If the shopper identifier is not beeing captured by the `bundle.js` context, it must be provided for proper personalization. ```html - - - ``` ### Filters -The example shown below will filter the recommendations for products matching color: blue, & red, and price range 0 - 20. +The example shown below will filter the recommendations for products matching field `color` with a value `blue` and `red`, as well as a field `price` with a range from `0` to `20`. ```html - ``` \ No newline at end of file diff --git a/docs/documents.js b/docs/documents.js index 22d74ff0d..cccf98813 100644 --- a/docs/documents.js +++ b/docs/documents.js @@ -165,6 +165,14 @@ var documents = [ url: './docs/INTEGRATION_RECOMMENDATIONS.md', searchable: true, }, + { + label: 'Recommendations (legacy)', + route: '/integration-legacy-recommendations', + type: 'markdown', + url: './docs/INTEGRATION_LEGACY_RECOMMENDATIONS.md', + searchable: true, + hidden: true, + }, { label: 'Tracking', route: '/integration-tracking', diff --git a/index.html b/index.html index e2ee9e914..2150e20e5 100644 --- a/index.html +++ b/index.html @@ -42,6 +42,7 @@ { a: "(https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_DEBUGGING.md)", b: "(#/integration-debugging)"}, { a: "(https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_TRACKING.md)", b: "(#/integration-tracking)"}, { a: "(https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_RECOMMENDATIONS.md)", b: "(#/integration-recommendations)"}, + { a: "(https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_LEGACY_RECOMMENDATIONS.md)", b: "(#/integration-legacy-recommendations)"}, { a: "https://github.com/searchspring/snap/blob/main/images/snap-dependencies.jpg?raw=true", b: "./images/snap-dependencies.jpg"}, { a: "https://github.com/searchspring/snap/blob/main/images/snap-dependencies.png?raw=true", b: "./images/snap-dependencies.png"}, @@ -244,7 +245,7 @@ app.component('Link', { props: ['link', 'active'], template: ` - + {{link.label}} diff --git a/packages/snap-client/src/Client/Client.test.ts b/packages/snap-client/src/Client/Client.test.ts index 5e38059f7..03e6c8c7d 100644 --- a/packages/snap-client/src/Client/Client.test.ts +++ b/packages/snap-client/src/Client/Client.test.ts @@ -351,18 +351,24 @@ describe('Snap Client', () => { const profileCacheKey = '{"tag":"dress","siteId":"8uyt2m"}'; const recommendParams = { - headers: {}, - method: 'GET', + headers: { + 'Content-Type': 'text/plain', + }, + method: 'POST', path: '/boost/8uyt2m/recommend', - query: { - limits: [20], - siteId: '8uyt2m', - tags: ['dress'], + body: { + profiles: [ + { + limit: 20, + tag: 'dress', + }, + ], test: true, + siteId: '8uyt2m', }, }; - const recommendCacheKey = '{"tags":["dress"],"limits":[20],"siteId":"8uyt2m","test":true}'; + const recommendCacheKey = '{"profiles":[{"tag":"dress","limit":20}],"siteId":"8uyt2m","test":true}'; expect(recommendRequesterSpy).toHaveBeenCalledTimes(2); expect(recommendRequesterSpy.mock.calls).toEqual([ @@ -592,18 +598,24 @@ describe('Snap Client', () => { const profileCacheKey = '{"tag":"dress","siteId":"8uyt2m"}'; const recommendParams = { - headers: {}, - method: 'GET', + headers: { + 'Content-Type': 'text/plain', + }, + method: 'POST', path: '/boost/8uyt2m/recommend', - query: { - limits: [20], - siteId: '8uyt2m', - tags: ['dress'], + body: { + profiles: [ + { + limit: 20, + tag: 'dress', + }, + ], test: true, + siteId: '8uyt2m', }, }; - const recommendCacheKey = '{"tags":["dress"],"limits":[20],"siteId":"8uyt2m","test":true}'; + const recommendCacheKey = '{"profiles":[{"tag":"dress","limit":20}],"siteId":"8uyt2m","test":true}'; expect(recommendRequesterSpy).toHaveBeenCalledTimes(2); expect(recommendRequesterSpy.mock.calls).toEqual([ diff --git a/packages/snap-client/src/Client/Client.ts b/packages/snap-client/src/Client/Client.ts index 5ba442850..be7f5f5a8 100644 --- a/packages/snap-client/src/Client/Client.ts +++ b/packages/snap-client/src/Client/Client.ts @@ -7,9 +7,8 @@ import type { TrendingRequestModel, TrendingResponseModel, ProfileRequestModel, - RecommendRequestModel, - RecommendCombinedRequestModel, RecommendCombinedResponseModel, + RecommendRequestModel, } from '../types'; import type { @@ -172,7 +171,7 @@ export class Client { return this.requesters.suggest.getTrending(params as TrendingRequestModel); } - async recommend(params: RecommendCombinedRequestModel): Promise { + async recommend(params: RecommendRequestModel): Promise { const { tag, ...otherParams } = params; if (!tag) { throw 'tag parameter is required'; @@ -188,8 +187,8 @@ export class Client { delete otherParams.branch; } - const recommendParams: RecommendRequestModel = { - tags: [tag], + const recommendParams = { + tag: tag, ...otherParams, siteId: params.siteId || this.globals.siteId, }; diff --git a/packages/snap-client/src/Client/apis/Legacy.test.ts b/packages/snap-client/src/Client/apis/Legacy.test.ts index 6567785b3..e49084619 100644 --- a/packages/snap-client/src/Client/apis/Legacy.test.ts +++ b/packages/snap-client/src/Client/apis/Legacy.test.ts @@ -64,16 +64,16 @@ describe('Legacy Api', () => { .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve({}) } as Response)); const params = { - body: '{"siteId":"88uyt2m"}', + body: '{"siteId":"abc123"}', headers: { 'Content-Type': 'application/json', }, method: 'POST', }; - const reuestUrl = 'https://88uyt2m.a.searchspring.io/api/meta/meta.json'; + const reuestUrl = 'https://abc123.a.searchspring.io/api/meta/meta.json'; await api.postMeta({ - siteId: '88uyt2m', + siteId: 'abc123', }); expect(requestMock).toHaveBeenCalledWith(reuestUrl, params); diff --git a/packages/snap-client/src/Client/apis/Recommend.test.ts b/packages/snap-client/src/Client/apis/Recommend.test.ts index 3e81d752a..acbd9b476 100644 --- a/packages/snap-client/src/Client/apis/Recommend.test.ts +++ b/packages/snap-client/src/Client/apis/Recommend.test.ts @@ -3,7 +3,7 @@ import { ApiConfiguration } from './Abstract'; import { RecommendAPI } from './Recommend'; import { MockData } from '@searchspring/snap-shared'; -import type { RecommendRequestModel } from '../../types'; +import type { RecommendPostRequestModel } from '../../types'; const mockData = new MockData(); @@ -24,8 +24,6 @@ describe('Recommend Api', () => { expect(api?.batchRecommendations).toBeDefined(); - expect(api?.getRecommendations).toBeDefined(); - expect(api?.postRecommendations).toBeDefined(); }); @@ -54,51 +52,33 @@ describe('Recommend Api', () => { requestMock.mockClear(); }); - it('can call getRecommendations', async () => { - const api = new RecommendAPI(new ApiConfiguration({})); - - const params = { - method: 'GET', - headers: {}, - }; - - const requestUrl = 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?siteId=8uyt2m&tags=dress'; - - const requestMock = jest - .spyOn(global.window, 'fetch') - .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve({}) } as Response)); - - await api.getRecommendations({ - siteId: '8uyt2m', - tags: ['dress'], - }); - - expect(requestMock).toHaveBeenCalledWith(requestUrl, params); - - requestMock.mockClear(); - }); - it('can call postRecommendations', async () => { const api = new RecommendAPI(new ApiConfiguration({})); const params = { method: 'POST', - body: '{"siteId":"88uyt2m","tags":["dress"]}', + body: '{"siteId":"8uyt2m","profiles":[{"tag":"dress"}]}', headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'text/plain', }, }; - const requestUrl = 'https://88uyt2m.a.searchspring.io/boost/88uyt2m/recommend'; + const requestParameters: RecommendPostRequestModel = { + siteId: '8uyt2m', + profiles: [ + { + tag: 'dress', + }, + ], + }; + + const requestUrl = 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend'; const requestMock = jest .spyOn(global.window, 'fetch') .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve({}) } as Response)); - await api.postRecommendations({ - siteId: '88uyt2m', - tags: ['dress'], - }); + await api.postRecommendations(requestParameters); expect(requestMock).toHaveBeenCalledWith(requestUrl, params); @@ -109,11 +89,14 @@ describe('Recommend Api', () => { const api = new RecommendAPI(new ApiConfiguration({})); const params = { - method: 'GET', - headers: {}, + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: `{"profiles":[{"tag":"similar","limit":20}],"siteId":"8uyt2m"}`, }; - const requestUrl = 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?tags=similar&limits=20&siteId=8uyt2m'; + const requestUrl = 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend'; const requestMock = jest .spyOn(global.window, 'fetch') @@ -121,7 +104,7 @@ describe('Recommend Api', () => { await api.batchRecommendations({ siteId: '8uyt2m', - tags: ['similar'], + tag: 'similar', }); //add delay for paramBatch.timeout @@ -131,12 +114,7 @@ describe('Recommend Api', () => { requestMock.mockReset(); }); - const GETParams = { - method: 'GET', - headers: {}, - }; - - const batchParams: Partial = { + const batchParams = { siteId: '8uyt2m', lastViewed: [ 'marnie-runner-2-7x10', @@ -158,6 +136,8 @@ describe('Recommend Api', () => { product: 'marnie-runner-2-7x10', }; + const RequestUrl = 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend'; + it('batchRecommendations batches as expected', async () => { const api = new RecommendAPI(new ApiConfiguration({})); @@ -165,21 +145,16 @@ describe('Recommend Api', () => { .spyOn(global.window, 'fetch') .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve(mockData.recommend()) } as Response)); - const GETRequestUrl = - 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?tags=similar&tags=crossSell&limits=14&limits=10&siteId=8uyt2m&lastViewed=marnie-runner-2-7x10&lastViewed=ruby-runner-2-7x10&lastViewed=abbie-runner-2-7x10&lastViewed=riley-4x6&lastViewed=joely-5x8&lastViewed=helena-4x6&lastViewed=kwame-4x6&lastViewed=sadie-4x6&lastViewed=candice-runner-2-7x10&lastViewed=esmeray-4x6&lastViewed=camilla-230x160&lastViewed=candice-4x6&lastViewed=sahara-4x6&lastViewed=dayna-4x6&lastViewed=moema-4x6&product=marnie-runner-2-7x10'; - - //product array changed to single product string - // @ts-ignore api.batchRecommendations({ - tags: ['similar'], - limits: 14, + tag: 'similar', + limit: 14, batched: true, ...batchParams, }); - // @ts-ignore + api.batchRecommendations({ - tags: ['crossSell'], - limits: 10, + tag: 'crossSell', + limit: 10, batched: true, ...batchParams, }); @@ -187,7 +162,15 @@ describe('Recommend Api', () => { //add delay for paramBatch.timeout await wait(250); - expect(requestMock).toHaveBeenCalledWith(GETRequestUrl, GETParams); + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: '{"profiles":[{"tag":"similar","limit":14},{"tag":"crossSell","limit":10}],"siteId":"8uyt2m","products":["marnie-runner-2-7x10"],"lastViewed":["marnie-runner-2-7x10","ruby-runner-2-7x10","abbie-runner-2-7x10","riley-4x6","joely-5x8","helena-4x6","kwame-4x6","sadie-4x6","candice-runner-2-7x10","esmeray-4x6","camilla-230x160","candice-4x6","sahara-4x6","dayna-4x6","moema-4x6"]}', + }; + + expect(requestMock).toHaveBeenCalledWith(RequestUrl, POSTParams); requestMock.mockReset(); }); @@ -198,32 +181,26 @@ describe('Recommend Api', () => { .spyOn(global.window, 'fetch') .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve(mockData.recommend()) } as Response)); - const GETRequestUrl = - 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?tags=similar&tags=crossSell&tags=crossSell&limits=14&limits=10&limits=10&categories=shirts&categories=pants&siteId=8uyt2m&lastViewed=marnie-runner-2-7x10&lastViewed=ruby-runner-2-7x10&lastViewed=abbie-runner-2-7x10&lastViewed=riley-4x6&lastViewed=joely-5x8&lastViewed=helena-4x6&lastViewed=kwame-4x6&lastViewed=sadie-4x6&lastViewed=candice-runner-2-7x10&lastViewed=esmeray-4x6&lastViewed=camilla-230x160&lastViewed=candice-4x6&lastViewed=sahara-4x6&lastViewed=dayna-4x6&lastViewed=moema-4x6&product=marnie-runner-2-7x10'; - //shirt category - // @ts-ignore api.batchRecommendations({ - tags: ['similar'], + tag: 'similar', categories: ['shirts'], - limits: 14, + limit: 14, batched: true, ...batchParams, }); //no category - // @ts-ignore api.batchRecommendations({ - tags: ['crossSell'], - limits: 10, + tag: 'crossSell', + limit: 10, batched: true, ...batchParams, }); //pants category - // @ts-ignore api.batchRecommendations({ - tags: ['crossSell'], + tag: 'crossSell', categories: ['pants'], - limits: 10, + limit: 10, batched: true, ...batchParams, }); @@ -231,7 +208,15 @@ describe('Recommend Api', () => { //add delay for paramBatch.timeout await wait(250); - expect(requestMock).toHaveBeenCalledWith(GETRequestUrl, GETParams); + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: '{"profiles":[{"tag":"similar","categories":["shirts"],"limit":14},{"tag":"crossSell","limit":10},{"tag":"crossSell","categories":["pants"],"limit":10}],"siteId":"8uyt2m","products":["marnie-runner-2-7x10"],"lastViewed":["marnie-runner-2-7x10","ruby-runner-2-7x10","abbie-runner-2-7x10","riley-4x6","joely-5x8","helena-4x6","kwame-4x6","sadie-4x6","candice-runner-2-7x10","esmeray-4x6","camilla-230x160","candice-4x6","sahara-4x6","dayna-4x6","moema-4x6"]}', + }; + + expect(requestMock).toHaveBeenCalledWith(RequestUrl, POSTParams); requestMock.mockReset(); }); @@ -242,23 +227,19 @@ describe('Recommend Api', () => { .spyOn(global.window, 'fetch') .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve(mockData.recommend()) } as Response)); - const GETRequestUrl = `https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?tags=similar&tags=crossSell&limits=14&limits=10&brands=shirts&brands=pants&brands=pants2&siteId=8uyt2m&lastViewed=marnie-runner-2-7x10&lastViewed=ruby-runner-2-7x10&lastViewed=abbie-runner-2-7x10&lastViewed=riley-4x6&lastViewed=joely-5x8&lastViewed=helena-4x6&lastViewed=kwame-4x6&lastViewed=sadie-4x6&lastViewed=candice-runner-2-7x10&lastViewed=esmeray-4x6&lastViewed=camilla-230x160&lastViewed=candice-4x6&lastViewed=sahara-4x6&lastViewed=dayna-4x6&lastViewed=moema-4x6&product=marnie-runner-2-7x10`; - //shirt category - // @ts-ignore api.batchRecommendations({ - tags: ['similar'], + tag: 'similar', brands: ['shirts'], - limits: 14, + limit: 14, batched: true, ...batchParams, }); //pants category - // @ts-ignore api.batchRecommendations({ - tags: ['crossSell'], + tag: 'crossSell', brands: ['pants', 'pants2'], - limits: 10, + limit: 10, batched: true, ...batchParams, }); @@ -266,7 +247,15 @@ describe('Recommend Api', () => { //add delay for paramBatch.timeout await wait(250); - expect(requestMock).toHaveBeenCalledWith(GETRequestUrl, GETParams); + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: '{"profiles":[{"tag":"similar","brands":["shirts"],"limit":14},{"tag":"crossSell","brands":["pants","pants2"],"limit":10}],"siteId":"8uyt2m","products":["marnie-runner-2-7x10"],"lastViewed":["marnie-runner-2-7x10","ruby-runner-2-7x10","abbie-runner-2-7x10","riley-4x6","joely-5x8","helena-4x6","kwame-4x6","sadie-4x6","candice-runner-2-7x10","esmeray-4x6","camilla-230x160","candice-4x6","sahara-4x6","dayna-4x6","moema-4x6"]}', + }; + + expect(requestMock).toHaveBeenCalledWith(RequestUrl, POSTParams); requestMock.mockReset(); }); @@ -278,77 +267,51 @@ describe('Recommend Api', () => { .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve(mockData.recommend()) } as Response)); //shirt category - // @ts-ignore api.batchRecommendations({ - tags: ['similar'], + tag: 'similar', categories: ['shirts'], - limits: 14, + limit: 14, order: 3, batched: true, ...batchParams, }); //no order - // @ts-ignore api.batchRecommendations({ - tags: ['crossSell'], - limits: 10, + tag: 'crossSell', + limit: 10, batched: true, ...batchParams, }); //no category - // @ts-ignore api.batchRecommendations({ - tags: ['crossSell'], - limits: 10, + tag: 'crossSell', + limit: 10, order: 2, batched: true, ...batchParams, }); //pants category - // @ts-ignore api.batchRecommendations({ - tags: ['crossSell'], + tag: 'crossSell', categories: ['pants'], - limits: 10, + limit: 10, order: 1, batched: true, ...batchParams, }); - //add delay for paramBatch.timeout - await wait(250); - const reorderedGetURL = - 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?tags=crossSell&tags=crossSell&tags=similar&tags=crossSell&limits=10&limits=10&limits=14&limits=10&categories=pants&categories=shirts&siteId=8uyt2m&lastViewed=marnie-runner-2-7x10&lastViewed=ruby-runner-2-7x10&lastViewed=abbie-runner-2-7x10&lastViewed=riley-4x6&lastViewed=joely-5x8&lastViewed=helena-4x6&lastViewed=kwame-4x6&lastViewed=sadie-4x6&lastViewed=candice-runner-2-7x10&lastViewed=esmeray-4x6&lastViewed=camilla-230x160&lastViewed=candice-4x6&lastViewed=sahara-4x6&lastViewed=dayna-4x6&lastViewed=moema-4x6&product=marnie-runner-2-7x10'; - expect(requestMock).toHaveBeenCalledWith(reorderedGetURL, GETParams); - requestMock.mockReset(); - }); - - it('batchRecommendations handles filters expected', async () => { - const api = new RecommendAPI(new ApiConfiguration({})); - - const requestMock = jest - .spyOn(global.window, 'fetch') - .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve(mockData.recommend()) } as Response)); - - // @ts-ignore - api.batchRecommendations({ - tags: ['crossSell'], - limits: 10, - filters: [ - { - type: 'value', - field: 'color', - value: 'red', - }, - ], - ...batchParams, - }); + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: '{"profiles":[{"tag":"crossSell","categories":["pants"],"limit":10},{"tag":"crossSell","limit":10},{"tag":"similar","categories":["shirts"],"limit":14},{"tag":"crossSell","limit":10}],"siteId":"8uyt2m","products":["marnie-runner-2-7x10"],"lastViewed":["marnie-runner-2-7x10","ruby-runner-2-7x10","abbie-runner-2-7x10","riley-4x6","joely-5x8","helena-4x6","kwame-4x6","sadie-4x6","candice-runner-2-7x10","esmeray-4x6","camilla-230x160","candice-4x6","sahara-4x6","dayna-4x6","moema-4x6"]}', + }; //add delay for paramBatch.timeout await wait(250); - const reorderedGetURL = - 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?tags=crossSell&limits=10&siteId=8uyt2m&lastViewed=marnie-runner-2-7x10&lastViewed=ruby-runner-2-7x10&lastViewed=abbie-runner-2-7x10&lastViewed=riley-4x6&lastViewed=joely-5x8&lastViewed=helena-4x6&lastViewed=kwame-4x6&lastViewed=sadie-4x6&lastViewed=candice-runner-2-7x10&lastViewed=esmeray-4x6&lastViewed=camilla-230x160&lastViewed=candice-4x6&lastViewed=sahara-4x6&lastViewed=dayna-4x6&lastViewed=moema-4x6&product=marnie-runner-2-7x10&filter.color=red'; - expect(requestMock).toHaveBeenCalledWith(reorderedGetURL, GETParams); + + expect(requestMock).toHaveBeenCalledWith(RequestUrl, POSTParams); requestMock.mockReset(); }); @@ -360,21 +323,19 @@ describe('Recommend Api', () => { .spyOn(global.window, 'fetch') .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve(response) } as Response)); - // @ts-ignore const promise1 = api.batchRecommendations({ - tags: ['similar'], + tag: 'similar', categories: ['shirts'], - limits: 10, + limit: 10, order: 2, batched: true, ...batchParams, }); - // @ts-ignore const promise2 = api.batchRecommendations({ - tags: ['crosssell'], + tag: 'crosssell', categories: ['dress'], - limits: 20, + limit: 20, order: 1, batched: true, ...batchParams, @@ -382,10 +343,16 @@ describe('Recommend Api', () => { //add delay for paramBatch.timeout await wait(250); - const reorderedGetURL = - 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?tags=crosssell&tags=similar&limits=20&limits=10&categories=dress&categories=shirts&siteId=8uyt2m&lastViewed=marnie-runner-2-7x10&lastViewed=ruby-runner-2-7x10&lastViewed=abbie-runner-2-7x10&lastViewed=riley-4x6&lastViewed=joely-5x8&lastViewed=helena-4x6&lastViewed=kwame-4x6&lastViewed=sadie-4x6&lastViewed=candice-runner-2-7x10&lastViewed=esmeray-4x6&lastViewed=camilla-230x160&lastViewed=candice-4x6&lastViewed=sahara-4x6&lastViewed=dayna-4x6&lastViewed=moema-4x6&product=marnie-runner-2-7x10'; - expect(requestMock).toHaveBeenCalledWith(reorderedGetURL, GETParams); + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: '{"profiles":[{"tag":"crosssell","categories":["dress"],"limit":20},{"tag":"similar","categories":["shirts"],"limit":10}],"siteId":"8uyt2m","products":["marnie-runner-2-7x10"],"lastViewed":["marnie-runner-2-7x10","ruby-runner-2-7x10","abbie-runner-2-7x10","riley-4x6","joely-5x8","helena-4x6","kwame-4x6","sadie-4x6","candice-runner-2-7x10","esmeray-4x6","camilla-230x160","candice-4x6","sahara-4x6","dayna-4x6","moema-4x6"]}', + }; + + expect(requestMock).toHaveBeenCalledWith(RequestUrl, POSTParams); const [response1, response2] = await Promise.all([promise1, promise2]); @@ -395,28 +362,65 @@ describe('Recommend Api', () => { requestMock.mockReset(); }); - it('batchRecommendations handles undefined limits', async () => { + it('batchRecommendations handles filters expected', async () => { const api = new RecommendAPI(new ApiConfiguration({})); const requestMock = jest .spyOn(global.window, 'fetch') .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve(mockData.recommend()) } as Response)); - const requestURL = - 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?tags=crossSell&limits=20&siteId=8uyt2m&lastViewed=marnie-runner-2-7x10&lastViewed=ruby-runner-2-7x10&lastViewed=abbie-runner-2-7x10&lastViewed=riley-4x6&lastViewed=joely-5x8&lastViewed=helena-4x6&lastViewed=kwame-4x6&lastViewed=sadie-4x6&lastViewed=candice-runner-2-7x10&lastViewed=esmeray-4x6&lastViewed=camilla-230x160&lastViewed=candice-4x6&lastViewed=sahara-4x6&lastViewed=dayna-4x6&lastViewed=moema-4x6&product=marnie-runner-2-7x10'; + api.batchRecommendations({ + tag: 'crossSell', + limit: 10, + filters: [ + { + type: 'value', + field: 'color', + value: 'red', + }, + ], + ...batchParams, + }); - //now consts try with no limits - // @ts-ignore + //add delay for paramBatch.timeout + await wait(250); + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: '{"profiles":[{"tag":"crossSell","limit":10,"filters":[{"field":"color","type":"=","values":["red"]}]}],"siteId":"8uyt2m","products":["marnie-runner-2-7x10"],"lastViewed":["marnie-runner-2-7x10","ruby-runner-2-7x10","abbie-runner-2-7x10","riley-4x6","joely-5x8","helena-4x6","kwame-4x6","sadie-4x6","candice-runner-2-7x10","esmeray-4x6","camilla-230x160","candice-4x6","sahara-4x6","dayna-4x6","moema-4x6"]}', + }; + + expect(requestMock).toHaveBeenCalledWith(RequestUrl, POSTParams); + requestMock.mockReset(); + }); + + it('batchRecommendations handles undefined limit', async () => { + const api = new RecommendAPI(new ApiConfiguration({})); + + const requestMock = jest + .spyOn(global.window, 'fetch') + .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve(mockData.recommend()) } as Response)); + + //now consts try with no limit api.batchRecommendations({ - tags: ['crossSell'], + tag: 'crossSell', ...batchParams, - limits: undefined, + limit: undefined, }); //add delay for paramBatch.timeout await wait(250); + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: '{"profiles":[{"tag":"crossSell","limit":20}],"siteId":"8uyt2m","products":["marnie-runner-2-7x10"],"lastViewed":["marnie-runner-2-7x10","ruby-runner-2-7x10","abbie-runner-2-7x10","riley-4x6","joely-5x8","helena-4x6","kwame-4x6","sadie-4x6","candice-runner-2-7x10","esmeray-4x6","camilla-230x160","candice-4x6","sahara-4x6","dayna-4x6","moema-4x6"]}', + }; - expect(requestMock).toHaveBeenCalledWith(requestURL, GETParams); + expect(requestMock).toHaveBeenCalledWith(RequestUrl, POSTParams); requestMock.mockReset(); }); @@ -427,16 +431,20 @@ describe('Recommend Api', () => { .spyOn(global.window, 'fetch') .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve(mockData.recommend()) } as Response)); - const requestURL = - 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?tags=crossSell&limits=20&products=some_sku&products=some_sku2&products=marnie-runner-2-7x10&siteId=8uyt2m&lastViewed=marnie-runner-2-7x10&lastViewed=ruby-runner-2-7x10&lastViewed=abbie-runner-2-7x10&lastViewed=riley-4x6&lastViewed=joely-5x8&lastViewed=helena-4x6&lastViewed=kwame-4x6&lastViewed=sadie-4x6&lastViewed=candice-runner-2-7x10&lastViewed=esmeray-4x6&lastViewed=camilla-230x160&lastViewed=candice-4x6&lastViewed=sahara-4x6&lastViewed=dayna-4x6&lastViewed=moema-4x6'; - - // @ts-ignore - api.batchRecommendations({ tags: ['crossSell'], products: ['some_sku', 'some_sku2'], ...batchParams }); + api.batchRecommendations({ tag: 'crossSell', products: ['some_sku', 'some_sku2'], ...batchParams }); //add delay for paramBatch.timeout await wait(250); - expect(requestMock).toHaveBeenCalledWith(requestURL, GETParams); + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: '{"profiles":[{"tag":"crossSell","limit":20}],"siteId":"8uyt2m","products":["some_sku","some_sku2","marnie-runner-2-7x10"],"lastViewed":["marnie-runner-2-7x10","ruby-runner-2-7x10","abbie-runner-2-7x10","riley-4x6","joely-5x8","helena-4x6","kwame-4x6","sadie-4x6","candice-runner-2-7x10","esmeray-4x6","camilla-230x160","candice-4x6","sahara-4x6","dayna-4x6","moema-4x6"]}', + }; + + expect(requestMock).toHaveBeenCalledWith(RequestUrl, POSTParams); requestMock.mockReset(); }); @@ -447,16 +455,20 @@ describe('Recommend Api', () => { .spyOn(global.window, 'fetch') .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve(mockData.recommend()) } as Response)); - const requestURL = - 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend?tags=undefined&limits=20&blockedItems=blocked_sku1&blockedItems=blocked_sku2&siteId=8uyt2m&lastViewed=marnie-runner-2-7x10&lastViewed=ruby-runner-2-7x10&lastViewed=abbie-runner-2-7x10&lastViewed=riley-4x6&lastViewed=joely-5x8&lastViewed=helena-4x6&lastViewed=kwame-4x6&lastViewed=sadie-4x6&lastViewed=candice-runner-2-7x10&lastViewed=esmeray-4x6&lastViewed=camilla-230x160&lastViewed=candice-4x6&lastViewed=sahara-4x6&lastViewed=dayna-4x6&lastViewed=moema-4x6&product=marnie-runner-2-7x10'; - - // @ts-ignore - api.batchRecommendations({ blockedItems: ['blocked_sku1', 'blocked_sku2'], ...batchParams }); + api.batchRecommendations({ tag: 'crossSell', blockedItems: ['blocked_sku1', 'blocked_sku2'], ...batchParams }); //add delay for paramBatch.timeout await wait(250); - expect(requestMock).toHaveBeenCalledWith(requestURL, GETParams); + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: '{"profiles":[{"tag":"crossSell","limit":20}],"siteId":"8uyt2m","products":["marnie-runner-2-7x10"],"blockedItems":["blocked_sku1","blocked_sku2"],"lastViewed":["marnie-runner-2-7x10","ruby-runner-2-7x10","abbie-runner-2-7x10","riley-4x6","joely-5x8","helena-4x6","kwame-4x6","sadie-4x6","candice-runner-2-7x10","esmeray-4x6","camilla-230x160","candice-4x6","sahara-4x6","dayna-4x6","moema-4x6"]}', + }; + + expect(requestMock).toHaveBeenCalledWith(RequestUrl, POSTParams); requestMock.mockReset(); }); @@ -467,12 +479,23 @@ describe('Recommend Api', () => { const POSTParams = { method: 'POST', headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'text/plain', }, + body: JSON.stringify({ - tags: Array.from({ length: 100 }, (item, index) => index + ''), - limits: Array(100).fill(20), + profiles: Array.from({ length: 100 }, (item, index) => { + return { + tag: index.toString(), + limit: 20, + filters: [ + { field: 'color', type: '=', values: ['blue'] }, + { field: 'price', type: '>=', values: [0] }, + { field: 'price', type: '<=', values: [20] }, + ], + }; + }), siteId: '8uyt2m', + products: ['marnie-runner-2-7x10'], lastViewed: [ 'marnie-runner-2-7x10', 'ruby-runner-2-7x10', @@ -490,14 +513,9 @@ describe('Recommend Api', () => { 'dayna-4x6', 'moema-4x6', ], - product: 'marnie-runner-2-7x10', - filters: [ - { field: 'color', type: '=', values: ['blue'] }, - { field: 'price', type: '>=', values: [0] }, - { field: 'price', type: '<=', values: [20] }, - ], }), }; + const POSTRequestUrl = 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend'; const POSTRequestMock = jest @@ -505,9 +523,8 @@ describe('Recommend Api', () => { .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve({}) } as Response)); for (let i = 0; i < 100; i++) { - // @ts-ignore api.batchRecommendations({ - tags: [i.toString()], + tag: i.toString(), ...batchParams, filters: [ { diff --git a/packages/snap-client/src/Client/apis/Recommend.ts b/packages/snap-client/src/Client/apis/Recommend.ts index 9c77ba1c8..593d3c559 100644 --- a/packages/snap-client/src/Client/apis/Recommend.ts +++ b/packages/snap-client/src/Client/apis/Recommend.ts @@ -1,8 +1,8 @@ import { API, ApiConfiguration } from './Abstract'; -import { HTTPHeaders, PostRecommendRequestFiltersModel, PostRecommendRequestModel, GetRecommendRequestModel } from '../../types'; -import { AppMode, charsParams } from '@searchspring/snap-toolbox'; -import { transformRecommendationFiltersGet, transformRecommendationFiltersPost } from '../transforms'; -import { ProfileRequestModel, ProfileResponseModel, RecommendRequestModel, RecommendResponseModel } from '../../types'; +import { HTTPHeaders, RecommendPostRequestFiltersModel, RecommendPostRequestProfileModel } from '../../types'; +import { AppMode } from '@searchspring/snap-toolbox'; +import { transformRecommendationFiltersPost } from '../transforms'; +import { ProfileRequestModel, ProfileResponseModel, RecommendResponseModel, RecommendRequestModel, RecommendPostRequestModel } from '../../types'; class Deferred { promise: Promise; @@ -27,7 +27,7 @@ export class RecommendAPI extends API { private batches: { [key: string]: { timeout: number | NodeJS.Timeout; - request: Partial; + request: RecommendPostRequestModel; entries: BatchEntry[]; }; }; @@ -54,9 +54,11 @@ export class RecommendAPI extends API { } async batchRecommendations(parameters: RecommendRequestModel): Promise { + const batchId = parameters.batchId || 1; + // set up batch key and deferred promises - const key = parameters.batched ? parameters.siteId : `${Math.random()}`; - const batch = (this.batches[key] = this.batches[key] || { timeout: null, request: { tags: [], limits: [] }, entries: [] }); + const key = parameters.batched ? `${parameters.siteId}:${batchId}` : `${Math.random()}:${batchId}`; + const batch = (this.batches[key] = this.batches[key] || { timeout: null, request: { profiles: [] }, entries: [] }); const deferred = new Deferred(); // add each request to the list @@ -70,48 +72,50 @@ export class RecommendAPI extends API { // delete the batch so a new one can take its place delete this.batches[key]; - // reorder the requests by order value in context. + //resort batch entries based on order batch.entries.sort(sortBatchEntries); // now that the requests are in proper order, map through them // and build out the batches batch.entries.map((entry) => { - const { tags, categories, brands, ...otherParams } = entry.request; - let limits = entry.request.limits; + const { tag, categories, brands, query, filters, dedupe } = entry.request; - if (!limits) { - limits = 20; + let transformedFilters; + if (filters) { + transformedFilters = transformRecommendationFiltersPost(filters) as RecommendPostRequestFiltersModel[]; } - const [tag] = tags || []; - - delete otherParams.batched; // remove from request parameters - delete otherParams.order; // remove from request parameters - delete otherParams.limits; - - batch.request.tags!.push(tag); - if (categories) { - if (!batch.request.categories) { - batch.request.categories = Array.isArray(categories) ? categories : [categories]; + const profile: RecommendPostRequestProfileModel = { + tag, + categories, + brands, + limit: entry.request.limit || 20, + searchTerm: query, + filters: transformedFilters, + dedupe, + }; + + batch.request.profiles?.push(profile); + + batch.request = { + ...batch.request, + siteId: parameters.siteId, + product: parameters.product, + products: parameters.products, + blockedItems: parameters.blockedItems, + test: parameters.test, + cart: parameters.cart, + lastViewed: parameters.lastViewed, + shopper: parameters.shopper, + } as RecommendPostRequestModel; + + // use products request only and combine when needed + if (batch.request.product) { + if (Array.isArray(batch.request.products) && batch.request.products.indexOf(batch.request.product) == -1) { + batch.request.products = batch.request.products.concat(batch.request.product); } else { - batch.request.categories = batch.request.categories.concat(categories); + batch.request.products = [batch.request.product]; } - } - - if (brands) { - if (!batch.request.brands) { - batch.request.brands = Array.isArray(brands) ? brands : [brands]; - } else { - batch.request.brands = batch.request.brands.concat(brands); - } - } - - batch.request.limits = (batch.request.limits as number[]).concat(limits); - batch.request = { ...batch.request, ...otherParams }; - - // combine product data if both 'product' and 'products' are used - if (batch.request.product && Array.isArray(batch.request.products)) { - batch.request.products = batch.request.products.concat(batch.request.product); delete batch.request.product; } @@ -122,35 +126,12 @@ export class RecommendAPI extends API { batch.request.test = true; } - let response: RecommendResponseModel; - if (charsParams(batch.request) > 1024) { - if (batch.request['product']) { - batch.request['product'] = batch.request['product'].toString(); - } - - //transform filters here - if (batch.request.filters) { - (batch.request as PostRecommendRequestModel)['filters'] = transformRecommendationFiltersPost( - batch.request.filters - ) as PostRecommendRequestFiltersModel[]; - } - - response = await this.postRecommendations(batch.request as PostRecommendRequestModel); - } else { - if (batch.request.filters) { - const filters = transformRecommendationFiltersGet(batch.request.filters); - if (filters) { - Object.keys(filters).map((filter) => { - const _filter = filter as `filter.${string}`; - (batch.request as GetRecommendRequestModel)[_filter] = filters[_filter as keyof typeof filters]; - }); - } - } - - delete batch.request.filters; - response = await this.getRecommendations(batch.request as GetRecommendRequestModel); + if (batch.request['product']) { + batch.request['product'] = batch.request['product'].toString(); } + const response = await this.postRecommendations(batch.request as RecommendPostRequestModel); + batch.entries?.forEach((entry, index) => { entry.deferred.resolve([response[index]]); }); @@ -164,28 +145,9 @@ export class RecommendAPI extends API { return deferred.promise; } - async getRecommendations(queryParameters: GetRecommendRequestModel): Promise { + async postRecommendations(requestParameters: RecommendPostRequestModel): Promise { const headerParameters: HTTPHeaders = {}; - - const siteId = queryParameters.siteId; - const path = `/boost/${siteId}/recommend`; - - const response = await this.request( - { - path, - method: 'GET', - headers: headerParameters, - query: queryParameters, - }, - JSON.stringify(queryParameters) - ); - - return response as unknown as RecommendResponseModel; - } - - async postRecommendations(requestParameters: PostRecommendRequestModel): Promise { - const headerParameters: HTTPHeaders = {}; - headerParameters['Content-Type'] = 'application/json'; + headerParameters['Content-Type'] = 'text/plain'; const siteId = requestParameters.siteId; const path = `/boost/${siteId}/recommend`; @@ -205,20 +167,23 @@ export class RecommendAPI extends API { } function sortBatchEntries(a: BatchEntry, b: BatchEntry) { + const one = a.request as RecommendRequestModel; + const two = b.request as RecommendRequestModel; + // undefined order goes last - if (a.request.order == undefined && b.request.order == undefined) { + if (one.order == undefined && two.order == undefined) { return 0; } - if (a.request.order == undefined && b.request.order != undefined) { + if (one.order == undefined && two.order != undefined) { return 1; } - if (b.request.order == undefined && a.request.order != undefined) { + if (two.order == undefined && one.order != undefined) { return -1; } - if (a.request.order! < b.request.order!) { + if (one.order! < two.order!) { return -1; } - if (a.request.order! > b.request.order!) { + if (one.order! > two.order!) { return 1; } return 0; diff --git a/packages/snap-client/src/Client/apis/Suggest.test.ts b/packages/snap-client/src/Client/apis/Suggest.test.ts index ba40014ac..46cfb7194 100644 --- a/packages/snap-client/src/Client/apis/Suggest.test.ts +++ b/packages/snap-client/src/Client/apis/Suggest.test.ts @@ -43,20 +43,20 @@ describe('Suggest Api', () => { const api = new SuggestAPI(new ApiConfiguration({})); const params = { - body: '{"siteId":"88uyt2m","query":"dress"}', + body: '{"siteId":"abc123","query":"dress"}', headers: { 'Content-Type': 'application/json', }, method: 'POST', }; - const requestUrl = 'https://88uyt2m.a.searchspring.io/api/suggest/query'; + const requestUrl = 'https://abc123.a.searchspring.io/api/suggest/query'; const requestMock = jest .spyOn(global.window, 'fetch') .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve({}) } as Response)); await api.postSuggest({ - siteId: '88uyt2m', + siteId: 'abc123', query: 'dress', }); diff --git a/packages/snap-client/src/Client/transforms/recommendationFiltersPost.ts b/packages/snap-client/src/Client/transforms/recommendationFiltersPost.ts index 6b04e447f..3b164690a 100644 --- a/packages/snap-client/src/Client/transforms/recommendationFiltersPost.ts +++ b/packages/snap-client/src/Client/transforms/recommendationFiltersPost.ts @@ -1,7 +1,7 @@ -import { RecommendationRequestFilterModel, PostRecommendRequestFiltersModel } from '../../types'; +import { RecommendationRequestFilterModel, RecommendPostRequestFiltersModel } from '../../types'; export const transformRecommendationFiltersPost = (filters: RecommendationRequestFilterModel[]) => { - const filterArray: PostRecommendRequestFiltersModel[] = []; + const filterArray: RecommendPostRequestFiltersModel[] = []; filters.map((filter) => { if (filter.type == 'value') { //check if filterArray contains a filter for this value already diff --git a/packages/snap-client/src/index.ts b/packages/snap-client/src/index.ts index c44802876..9837cffd0 100644 --- a/packages/snap-client/src/index.ts +++ b/packages/snap-client/src/index.ts @@ -1,3 +1,3 @@ export * from './Client/Client'; -export { ClientGlobals, ClientConfig, TrendingResponseModel, RecommendCombinedRequestModel, RecommendCombinedResponseModel } from './types'; +export { ClientGlobals, ClientConfig, TrendingResponseModel, RecommendRequestModel, RecommendCombinedResponseModel } from './types'; diff --git a/packages/snap-client/src/types.ts b/packages/snap-client/src/types.ts index 610f78ebc..5c1d50cae 100644 --- a/packages/snap-client/src/types.ts +++ b/packages/snap-client/src/types.ts @@ -103,8 +103,8 @@ export type TrendingResponseModel = { }; export type RecommendRequestModel = { - tags: string[]; - siteId: string; + tag: string; + siteId?: string; product?: string; products?: string[]; shopper?: string; @@ -113,22 +113,48 @@ export type RecommendRequestModel = { cart?: string[]; lastViewed?: string[]; test?: boolean; + branch?: string; + filters?: RecommendationRequestFilterModel[]; + blockedItems?: string[]; batched?: boolean; - limits?: number | number[]; + limit?: number; order?: number; - filters?: RecommendationRequestFilterModel[]; + batchId?: number; + query?: string; + dedupe?: boolean; +}; + +//TODO: move to snapi +export type RecommendPostRequestModel = { + siteId: string; + profiles: RecommendPostRequestProfileModel[]; + product?: string; + products?: string[]; + shopper?: string; + cart?: string[]; + lastViewed?: string[]; + test?: boolean; + withRecInfo?: boolean; blockedItems?: string[]; + filters?: RecommendPostRequestFiltersModel[]; }; -export type GetRecommendRequestModel = Omit & { - [filter: `filter.${string}`]: (string | number)[]; +export type RecommendPostRequestProfileModel = Omit & { + filters?: RecommendPostRequestFiltersModel[]; + searchTerm?: string; }; -export type PostRecommendRequestModel = Omit & { - filters?: PostRecommendRequestFiltersModel[]; +export type RecommendRequestProfileModel = { + tag: string; + categories?: string[]; + brands?: string[]; + limit?: number; + dedupe?: boolean; + query?: string; + filters?: RecommendationRequestFilterModel[]; }; -export type PostRecommendRequestFiltersModel = { +export type RecommendPostRequestFiltersModel = { field: string; type: '=' | '==' | '===' | '!=' | '!==' | '>' | '<' | '>=' | '<='; values: (string | number)[]; @@ -170,22 +196,6 @@ export type ProfileResponseModel = { }; }; -export type RecommendCombinedRequestModel = { - tag: string; - siteId: string; - product?: string; - products?: string[]; - shopper?: string; - categories?: string[]; - brands?: string[]; - cart?: string[]; - lastViewed?: string[]; - test?: boolean; - branch?: string; - filters?: RecommendationRequestFilterModel[]; - blockedItems?: string[]; -}; - export type RecommendationRequestFilterModel = RecommendationRequestRangeFilterModel | RecommendationRequestValueFilterModel; type RecommendationRequestRangeFilterModel = { diff --git a/packages/snap-controller/src/Recommendation/RecommendationController.test.ts b/packages/snap-controller/src/Recommendation/RecommendationController.test.ts index 59daf3557..5129050e7 100644 --- a/packages/snap-controller/src/Recommendation/RecommendationController.test.ts +++ b/packages/snap-controller/src/Recommendation/RecommendationController.test.ts @@ -140,7 +140,7 @@ describe('Recommendation Controller', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { action: 'navigate', @@ -153,7 +153,7 @@ describe('Recommendation Controller', () => { placement: controller.store.profile.placement, threshold: controller.store.profile.display.threshold, templateId: controller.store.profile.display.template.uuid, - seed: [{ sku: recommendConfig.globals.product }], + seed: [{ sku: recommendConfig.globals?.product }], }, }, }); @@ -176,7 +176,7 @@ describe('Recommendation Controller', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_PRODUCT_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { action: 'navigate', @@ -189,7 +189,7 @@ describe('Recommendation Controller', () => { mappings: { core: result.mappings.core, }, - seed: [{ sku: recommendConfig.globals.product }], + seed: [{ sku: recommendConfig.globals?.product }], }, }, pid: controller.events.click!.id, @@ -227,7 +227,7 @@ describe('Recommendation Controller', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { action: 'navigate', @@ -263,7 +263,7 @@ describe('Recommendation Controller', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_PRODUCT_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { action: 'navigate', @@ -314,7 +314,7 @@ describe('Recommendation Controller', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { action: 'navigate', @@ -350,7 +350,7 @@ describe('Recommendation Controller', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_PRODUCT_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { action: 'navigate', @@ -396,7 +396,7 @@ describe('Recommendation Controller', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_RENDER, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { placement: controller.store.profile.placement, @@ -408,7 +408,7 @@ describe('Recommendation Controller', () => { placement: controller.store.profile.placement, threshold: controller.store.profile.display.threshold, templateId: controller.store.profile.display.template.uuid, - seed: [{ sku: recommendConfig.globals.product }], + seed: [{ sku: recommendConfig.globals?.product }], }, }, }); @@ -454,7 +454,7 @@ describe('Recommendation Controller', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_RENDER, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { placement: controller.store.profile.placement, @@ -466,7 +466,7 @@ describe('Recommendation Controller', () => { placement: controller.store.profile.placement, threshold: controller.store.profile.display.threshold, templateId: controller.store.profile.display.template.uuid, - seed: [{ sku: recommendConfig.globals.product }], + seed: [{ sku: recommendConfig.globals?.product }], }, }, }); @@ -510,7 +510,7 @@ describe('Recommendation Controller', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_IMPRESSION, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { placement: controller.store.profile.placement, @@ -522,7 +522,7 @@ describe('Recommendation Controller', () => { placement: controller.store.profile.placement, threshold: controller.store.profile.display.threshold, templateId: controller.store.profile.display.template.uuid, - seed: [{ sku: recommendConfig.globals.product }], + seed: [{ sku: recommendConfig.globals?.product }], }, }, }); @@ -546,7 +546,7 @@ describe('Recommendation Controller', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_PRODUCT_IMPRESSION, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { placement: controller.store.profile.placement, @@ -558,7 +558,7 @@ describe('Recommendation Controller', () => { mappings: { core: result.mappings.core, }, - seed: [{ sku: recommendConfig.globals.product }], + seed: [{ sku: recommendConfig.globals?.product }], }, }, pid: controller.events.impression!.id, diff --git a/packages/snap-controller/src/Recommendation/RecommendationController.ts b/packages/snap-controller/src/Recommendation/RecommendationController.ts index 1b00410d7..db79a51e4 100644 --- a/packages/snap-controller/src/Recommendation/RecommendationController.ts +++ b/packages/snap-controller/src/Recommendation/RecommendationController.ts @@ -8,7 +8,7 @@ import { ControllerTypes } from '../types'; import type { ProductViewEvent } from '@searchspring/snap-tracker'; import type { RecommendationStore } from '@searchspring/snap-store-mobx'; import type { Next } from '@searchspring/snap-event-manager'; -import type { RecommendCombinedRequestModel } from '@searchspring/snap-client'; +import type { RecommendRequestModel } from '@searchspring/snap-client'; import type { RecommendationControllerConfig, BeforeSearchObj, AfterStoreObj, ControllerServices, ContextVariables } from '../types'; type RecommendationTrackMethods = { @@ -101,10 +101,10 @@ export class RecommendationController extends AbstractController { let skus: Array = []; switch (this.store.profile.placement) { case ProfilePlacement.PRODUCTPAGE: - if (this.config.globals.product) { - skus = [this.config.globals.product]; - } else if (this.config.globals.products) { - skus = this.config.globals.products; + if (this.config.globals?.product) { + skus = [this.config.globals?.product]; + } else if (this.config.globals?.products) { + skus = this.config.globals?.products; } break; case ProfilePlacement.BASKETPAGE: @@ -131,7 +131,7 @@ export class RecommendationController extends AbstractController { const payload: BeaconPayload = { type: BeaconType.PROFILE_PRODUCT_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: this.config.globals.siteId ? { website: { trackingCode: this.config.globals.siteId } } : undefined, + context: this.config.globals?.siteId ? { website: { trackingCode: this.config.globals?.siteId } } : undefined, event: { context: { action: 'navigate', @@ -160,7 +160,7 @@ export class RecommendationController extends AbstractController { const payload: BeaconPayload = { type: BeaconType.PROFILE_PRODUCT_IMPRESSION, category: BeaconCategory.RECOMMENDATIONS, - context: this.config.globals.siteId ? { website: { trackingCode: this.config.globals.siteId } } : undefined, + context: this.config.globals?.siteId ? { website: { trackingCode: this.config.globals?.siteId } } : undefined, event: { context: { placement: this.store.profile.placement, @@ -188,7 +188,7 @@ export class RecommendationController extends AbstractController { const payload: BeaconPayload = { type: BeaconType.PROFILE_PRODUCT_RENDER, category: BeaconCategory.RECOMMENDATIONS, - context: this.config.globals.siteId ? { website: { trackingCode: this.config.globals.siteId } } : undefined, + context: this.config.globals?.siteId ? { website: { trackingCode: this.config.globals?.siteId } } : undefined, event: { context: { placement: this.store.profile.placement, @@ -223,7 +223,7 @@ export class RecommendationController extends AbstractController { const payload: BeaconPayload = { type: BeaconType.PROFILE_PRODUCT_REMOVEDFROMBUNDLE, category: BeaconCategory.RECOMMENDATIONS, - context: this.config.globals.siteId ? { website: { trackingCode: this.config.globals.siteId } } : undefined, + context: this.config.globals?.siteId ? { website: { trackingCode: this.config.globals?.siteId } } : undefined, event: { context: { placement: this.store.profile.placement, @@ -258,7 +258,7 @@ export class RecommendationController extends AbstractController { const payload: BeaconPayload = { type: BeaconType.PROFILE_PRODUCT_ADDEDTOBUNDLE, category: BeaconCategory.RECOMMENDATIONS, - context: this.config.globals.siteId ? { website: { trackingCode: this.config.globals.siteId } } : undefined, + context: this.config.globals?.siteId ? { website: { trackingCode: this.config.globals?.siteId } } : undefined, event: { context: { placement: this.store.profile.placement, @@ -287,7 +287,7 @@ export class RecommendationController extends AbstractController { const event: BeaconEvent | undefined = this.tracker.track.event({ type: BeaconType.PROFILE_ADDBUNDLE, category: BeaconCategory.RECOMMENDATIONS, - context: this.config.globals.siteId ? { website: { trackingCode: this.config.globals.siteId } } : undefined, + context: this.config.globals?.siteId ? { website: { trackingCode: this.config.globals?.siteId } } : undefined, event: { context: { placement: this.store.profile.placement, @@ -318,7 +318,7 @@ export class RecommendationController extends AbstractController { const event: BeaconEvent | undefined = this.tracker.track.event({ type: BeaconType.PROFILE_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: this.config.globals.siteId ? { website: { trackingCode: this.config.globals.siteId } } : undefined, + context: this.config.globals?.siteId ? { website: { trackingCode: this.config.globals?.siteId } } : undefined, event: { context: { action: 'navigate', @@ -344,7 +344,7 @@ export class RecommendationController extends AbstractController { const event: BeaconEvent | undefined = this.tracker.track.event({ type: BeaconType.PROFILE_IMPRESSION, category: BeaconCategory.RECOMMENDATIONS, - context: this.config.globals.siteId ? { website: { trackingCode: this.config.globals.siteId } } : undefined, + context: this.config.globals?.siteId ? { website: { trackingCode: this.config.globals?.siteId } } : undefined, event: { context: { placement: this.store.profile.placement, @@ -369,7 +369,7 @@ export class RecommendationController extends AbstractController { const event: BeaconEvent | undefined = this.tracker.track.event({ type: BeaconType.PROFILE_RENDER, category: BeaconCategory.RECOMMENDATIONS, - context: this.config.globals.siteId ? { website: { trackingCode: this.config.globals.siteId } } : undefined, + context: this.config.globals?.siteId ? { website: { trackingCode: this.config.globals?.siteId } } : undefined, event: { context: { placement: this.store.profile.placement, @@ -394,12 +394,13 @@ export class RecommendationController extends AbstractController { } as RecommendationTrackMethods; })(); - get params(): RecommendCombinedRequestModel { - const params: RecommendCombinedRequestModel = { + get params(): RecommendRequestModel { + const params = { tag: this.config.tag, batched: this.config.batched, branch: this.config.branch || 'production', order: this.context?.options?.order, + batchId: this.config.batchId, ...this.config.globals, }; @@ -420,7 +421,7 @@ export class RecommendationController extends AbstractController { } } - return params; + return params as RecommendRequestModel; } search = async (): Promise => { diff --git a/packages/snap-preact-components/src/components/Trackers/Recommendation/ProfileTracker/RecommendationProfileTracker.test.tsx b/packages/snap-preact-components/src/components/Trackers/Recommendation/ProfileTracker/RecommendationProfileTracker.test.tsx index 432a126b7..b56cba496 100644 --- a/packages/snap-preact-components/src/components/Trackers/Recommendation/ProfileTracker/RecommendationProfileTracker.test.tsx +++ b/packages/snap-preact-components/src/components/Trackers/Recommendation/ProfileTracker/RecommendationProfileTracker.test.tsx @@ -85,7 +85,7 @@ describe('RecommendationProfileTracker Component', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_RENDER, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { placement: controller.store.profile.placement, @@ -109,7 +109,7 @@ describe('RecommendationProfileTracker Component', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { action: 'navigate', @@ -147,7 +147,7 @@ describe('RecommendationProfileTracker Component', () => { expect(trackfn).toHaveBeenNthCalledWith(1, { type: BeaconType.PROFILE_IMPRESSION, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { placement: controller.store.profile.placement, @@ -204,7 +204,7 @@ describe('RecommendationProfileTracker Component', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_RENDER, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { placement: controller.store.profile.placement, @@ -228,7 +228,7 @@ describe('RecommendationProfileTracker Component', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals?.siteId } } : undefined, event: { context: { action: 'navigate', diff --git a/packages/snap-preact-components/src/components/Trackers/Recommendation/ResultTracker/RecommendationResultTracker.test.tsx b/packages/snap-preact-components/src/components/Trackers/Recommendation/ResultTracker/RecommendationResultTracker.test.tsx index d4a428beb..eb7e09bdf 100644 --- a/packages/snap-preact-components/src/components/Trackers/Recommendation/ResultTracker/RecommendationResultTracker.test.tsx +++ b/packages/snap-preact-components/src/components/Trackers/Recommendation/ResultTracker/RecommendationResultTracker.test.tsx @@ -93,7 +93,7 @@ describe('RecommendationResultTracker Component', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_PRODUCT_RENDER, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, pid: controller.events.render!.id, event: { context: { @@ -135,7 +135,7 @@ describe('RecommendationResultTracker Component', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_PRODUCT_IMPRESSION, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, pid: controller.events.impression?.id, event: { context: { @@ -165,7 +165,7 @@ describe('RecommendationResultTracker Component', () => { expect.objectContaining({ type: BeaconType.PROFILE_PRODUCT_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, // pid: controller.events.click?.id, event: { context: { @@ -224,7 +224,7 @@ describe('RecommendationResultTracker Component', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_PRODUCT_RENDER, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, pid: controller.events.render!.id, event: { context: { @@ -270,7 +270,7 @@ describe('RecommendationResultTracker Component', () => { expect.objectContaining({ type: BeaconType.PROFILE_PRODUCT_CLICK, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, // pid: controller.events.click?.id, event: { context: { @@ -331,7 +331,7 @@ describe('RecommendationResultTracker Component', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_PRODUCT_RENDER, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, pid: controller.events.render!.id, event: { context: { @@ -373,7 +373,7 @@ describe('RecommendationResultTracker Component', () => { expect(trackfn).toHaveBeenCalledWith({ type: BeaconType.PROFILE_PRODUCT_IMPRESSION, category: BeaconCategory.RECOMMENDATIONS, - context: controller.config.globals.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, + context: controller.config.globals?.siteId ? { website: { trackingCode: controller.config.globals.siteId } } : undefined, pid: controller.events.impression?.id, event: { context: { diff --git a/packages/snap-preact-demo/public/email.html b/packages/snap-preact-demo/public/email.html index 01d6d8206..c80c22f66 100644 --- a/packages/snap-preact-demo/public/email.html +++ b/packages/snap-preact-demo/public/email.html @@ -28,9 +28,10 @@ const recommendResponse = [{"results":results,"profile":{"tag":profile}}]; const profileResponse = {"profile":{"tag":profile,"display":{"template":{"component":component},"templateParameters":{}}}}; - const profileKey = `/api/personalized-recommendations/profile.json/{"tag":"${profile}","siteId":"${siteID}","branch":"production"}`; - const recommendKey = `/boost/${siteID}/recommend/{"tags":["${profile}"],"limits":[20],"siteId":"8uyt2m"}`; - const wildCardKey = `/boost/${siteID}/recommend/*`; + const profileKey = `/api/personalized-recommendations/profile.json{"tag":"${profile}","siteId":"${siteID}","branch":"production"}`; + const recommendKey = `/boost/${siteID}/recommend{"tags":["${profile}"],"limits":[20],"siteId":"8uyt2m"}`; + const profileWildCardKey = `/api/personalized-recommendations/profile.json/*`; + const recommendWildCardKey = `/boost/${siteID}/recommend/*`; document.addEventListener('RecsReady', () => { @@ -55,8 +56,9 @@ cache: { entries: { [profileKey]: profileResponse, - //[recommendKey]: recommendResponse, - [wildCardKey]: recommendResponse, + [recommendKey]: recommendResponse, + [profileWildCardKey]: profileResponse, + [recommendWildCardKey]: recommendResponse, } }, diff --git a/packages/snap-preact-demo/public/product.html b/packages/snap-preact-demo/public/product.html index 01d902203..82281aadc 100644 --- a/packages/snap-preact-demo/public/product.html +++ b/packages/snap-preact-demo/public/product.html @@ -171,8 +171,18 @@

Stripe Out Off-The-Shoulder Dress

- diff --git a/packages/snap-preact-demo/public/recommendations.html b/packages/snap-preact-demo/public/recommendations.html new file mode 100644 index 000000000..fcfc8ebd9 --- /dev/null +++ b/packages/snap-preact-demo/public/recommendations.html @@ -0,0 +1,248 @@ + + + + + Product Detail Page + + + + + + + + + + + + + +
+ + + +
+ +
+
+
+ +
+
+
+
+ +
+ +
stuff...
+ + + + + + +
+
+ + + + + + + \ No newline at end of file diff --git a/packages/snap-preact-demo/src/components/Recommendations/Recs/Recs.tsx b/packages/snap-preact-demo/src/components/Recommendations/Recs/Recs.tsx index 13b16fecd..460906692 100644 --- a/packages/snap-preact-demo/src/components/Recommendations/Recs/Recs.tsx +++ b/packages/snap-preact-demo/src/components/Recommendations/Recs/Recs.tsx @@ -1,7 +1,7 @@ import { h, Component } from 'preact'; import { observer } from 'mobx-react'; -import { Carousel, Recommendation, Result } from '@searchspring/snap-preact-components'; +import { Recommendation, Result } from '@searchspring/snap-preact-components'; type RecsProps = { controller?: RecommendationController; @@ -22,18 +22,12 @@ export class Recs extends Component { render() { const controller = this.props.controller; const store = controller?.store; - const arr = Array.from(Array(9).keys()); + const parameters = store?.profile?.display?.templateParameters; return (
- - {arr.map((num) => ( -
{num}!!!
- ))} -
-
- + {store.results.map((result) => ( ))} diff --git a/packages/snap-preact-demo/tests/cypress/e2e/recommendation/recommendation.cy.js b/packages/snap-preact-demo/tests/cypress/e2e/recommendation/recommendation.cy.js index 348296818..1a5ce82f9 100644 --- a/packages/snap-preact-demo/tests/cypress/e2e/recommendation/recommendation.cy.js +++ b/packages/snap-preact-demo/tests/cypress/e2e/recommendation/recommendation.cy.js @@ -10,128 +10,154 @@ */ const config = { - url: 'https://localhost:2222/product.html', // page containing autocomplete (recommended: home/about/contact page) + url: 'https://localhost:2222/recommendations.html', // page containing autocomplete (recommended: home/about/contact page) disableGA: '', // disable google analytic events (example: 'UA-123456-1') - selectors: { - recommendation: { - main: '.ss__recommendation', - // selector of the wrapping element. Expects child element to contain - carousel: `.ss__recommendation .ss__carousel`, - result: '.ss__result', - nextArrow: '.ss__recommendation .ss__carousel__next', - prevArrow: '.ss__recommendation .ss__carousel__prev', - activeSlide: '.ss__recommendation .swiper-slide-active', - controller: 'recommend_similar_0', + integrations: [ + { + label: 'New', + selectors: { + recommendation: { + main: '.ss__recs__similar .ss__recommendation', + // selector of the wrapping element. Expects child element to contain + carousel: `.ss__recs__similar .ss__recommendation .ss__carousel`, + result: '.ss__result', + nextArrow: '.ss__recs__similar .ss__recommendation .ss__carousel__next', + prevArrow: '.ss__recs__similar .ss__recommendation .ss__carousel__prev', + activeSlide: '.ss__recs__similar .ss__recommendation .swiper-slide-active', + controller: 'recommend_similar_0', + }, + }, }, - }, + { + label: 'Legacy', + selectors: { + recommendation: { + main: '[searchspring-recommend="similar"] .ss__recommendation', + // selector of the wrapping element. Expects child element to contain + carousel: `[searchspring-recommend="similar"] .ss__recommendation .ss__carousel`, + result: '.ss__result', + nextArrow: '[searchspring-recommend="similar"] .ss__recommendation .ss__carousel__next', + prevArrow: '[searchspring-recommend="similar"] .ss__recommendation .ss__carousel__prev', + activeSlide: '[searchspring-recommend="similar"] .ss__recommendation .swiper-slide-active', + controller: 'recommend_similar_1', + }, + }, + }, + ], }; describe('Recommendations', () => { - describe('Setup', () => { - it('has valid config', () => { - cy.wrap(config).its('url').should('have.length.at.least', 1); - cy.visit(config.url); - console.log(Cypress.browser); - }); + config.integrations.forEach((integration) => { + describe(`${integration.label}`, () => { + describe('Setup', () => { + it('has valid config', () => { + cy.wrap(config).its('url').should('have.length.at.least', 1); + cy.visit(config.url); + console.log(Cypress.browser); + }); - it('snap bundle exists on product page', () => { - cy.waitForBundle().then((searchspring) => { - expect(searchspring).to.exist; + it('snap bundle exists on product page', () => { + cy.waitForBundle().then((searchspring) => { + expect(searchspring).to.exist; + }); + }); }); - }); - }); - describe('Tests Recommendations', () => { - it('has a controller', function () { - cy.snapController(config?.selectors?.recommendation.controller).then(({ store }) => { - expect(store.config.globals.limits).equals(store.results.length); - expect(store.config.globals.product.length).to.greaterThan(0); - }); - }); + describe('Tests Recommendations', () => { + it('has a controller', function () { + cy.snapController(integration?.selectors?.recommendation.controller).then(({ store }) => { + expect(store.config.globals.limit).equals(store.results.length); + expect((store.config.globals.product || store.config.globals.products).length).to.be.greaterThan(0); + }); + }); - it('renders recommendations', function () { - cy.snapController(config?.selectors?.recommendation.controller).then(({ store }) => { - cy.get(config?.selectors?.recommendation.main).should('exist'); + it('renders recommendations', function () { + cy.snapController(integration?.selectors?.recommendation.controller).then(({ store }) => { + cy.get(integration?.selectors?.recommendation.main).should('exist'); - cy.get(config?.selectors?.recommendation.carousel).should('exist'); - cy.get(config?.selectors?.recommendation.result).should('exist'); - }); - }); + cy.get(integration?.selectors?.recommendation.carousel).should('exist'); + cy.get(integration?.selectors?.recommendation.result).should('exist'); + }); + }); + + it('renders carousel prev buttons', function () { + cy.document().then((doc) => { + cy.snapController(integration?.selectors?.recommendation.controller).then(({ store }) => { + cy.get(integration?.selectors?.recommendation.nextArrow).should('exist'); + cy.get(integration?.selectors?.recommendation.prevArrow).should('exist'); + + cy.get(integration?.selectors?.recommendation.activeSlide).should('exist'); - it('renders carousel prev buttons', function () { - cy.document().then((doc) => { - cy.snapController(config?.selectors?.recommendation.controller).then(({ store }) => { - cy.get(config?.selectors?.recommendation.nextArrow).should('exist'); - cy.get(config?.selectors?.recommendation.prevArrow).should('exist'); - - cy.get(config?.selectors?.recommendation.activeSlide).should('exist'); - - //get the initial active product - const intialActive = doc.querySelector( - `${config?.selectors?.recommendation.activeSlide} ${config?.selectors?.recommendation.result} .ss__result__details__title a` - ).innerHTML; - - //click the prev button - cy.get(config?.selectors?.recommendation.prevArrow) - .click() - .then(($button) => { - const newerActiveTitle = doc.querySelector( - `${config?.selectors?.recommendation.activeSlide} ${config?.selectors?.recommendation.result} .ss__result__details__title a` + //get the initial active product + const intialActive = doc.querySelector( + `${integration?.selectors?.recommendation.activeSlide} ${integration?.selectors?.recommendation.result} .ss__result__details__title a` ).innerHTML; - //these should not match - expect(newerActiveTitle).to.not.equal(intialActive); + //click the prev button + cy.get(integration?.selectors?.recommendation.prevArrow) + .click() + .then(($button) => { + const newerActiveTitle = doc.querySelector( + `${integration?.selectors?.recommendation.activeSlide} ${integration?.selectors?.recommendation.result} .ss__result__details__title a` + ).innerHTML; + + //these should not match + expect(newerActiveTitle).to.not.equal(intialActive); + }); }); + }); }); - }); - }); - it.skip('renders carousel next buttons', function () { - cy.document().then((doc) => { - cy.snapController(config?.selectors?.recommendation.controller).then(({ store }) => { - cy.get(config?.selectors?.recommendation.nextArrow).should('exist'); - cy.get(config?.selectors?.recommendation.prevArrow).should('exist'); - - cy.get(config?.selectors?.recommendation.activeSlide).should('exist'); - - //get the initial active product - const intialActive = doc.querySelector( - `${config?.selectors?.recommendation.activeSlide} ${config?.selectors?.recommendation.result} .ss__result__details__title a` - ).innerHTML; - let newActive; - //click the next button - cy.get(config?.selectors?.recommendation.nextArrow) - .click() - .then(($button) => { - //get the new active product - newActive = doc.querySelector( - `${config?.selectors?.recommendation.activeSlide} ${config?.selectors?.recommendation.result} .ss__result__details__title a` - ).innerHTML; - - //get the new active again + it('renders carousel next buttons', function () { + cy.document().then((doc) => { + cy.snapController(integration?.selectors?.recommendation.controller).then(({ store }) => { + cy.get(integration?.selectors?.recommendation.nextArrow).should('exist'); + cy.get(integration?.selectors?.recommendation.prevArrow).should('exist'); - const newerActiveIndex = doc.querySelector(`${config?.selectors?.recommendation.activeSlide}`).getAttribute('data-swiper-slide-index'); - const storeTitle = store.results[parseInt(newerActiveIndex)].mappings.core.name; + cy.get(integration?.selectors?.recommendation.activeSlide).should('exist'); - //should have changed - expect(newActive).to.not.equal(intialActive); - expect(newActive).to.equal(storeTitle); + //get the initial active product + const intialActive = doc.querySelector( + `${integration?.selectors?.recommendation.activeSlide} ${integration?.selectors?.recommendation.result} .ss__result__details__title a` + ).innerHTML; + let newActive; + //click the next button + cy.get(integration?.selectors?.recommendation.nextArrow) + .click() + .then(($button) => { + //get the new active product + newActive = doc.querySelector( + `${integration?.selectors?.recommendation.activeSlide} ${integration?.selectors?.recommendation.result} .ss__result__details__title a` + ).innerHTML; + + //get the new active again + + const newerActiveIndex = doc + .querySelector(`${integration?.selectors?.recommendation.activeSlide}`) + .getAttribute('data-swiper-slide-index'); + const storeTitle = store.results[parseInt(newerActiveIndex)].mappings.core.name; + + //should have changed + expect(newActive).to.not.equal(intialActive); + expect(newActive).to.equal(storeTitle); + }); }); + }); }); - }); - }); - it('can click on a result and go to that page', function () { - cy.document().then((doc) => { - cy.snapController(config?.selectors?.recommendation.controller).then(({ store }) => { - cy.get(config?.selectors?.recommendation.activeSlide).should('exist'); - let url = doc.querySelector(`${config?.selectors?.recommendation.activeSlide} ${config?.selectors?.recommendation.result} a`).attributes - ?.href?.value; - cy.get(config?.selectors?.recommendation.activeSlide) - .click({ multiple: true }) - .then(() => { - cy.location('pathname').should('include', url); + it('can click on a result and go to that page', function () { + cy.document().then((doc) => { + cy.snapController(integration?.selectors?.recommendation.controller).then(({ store }) => { + cy.get(integration?.selectors?.recommendation.activeSlide).should('exist'); + let url = doc.querySelector(`${integration?.selectors?.recommendation.activeSlide} ${integration?.selectors?.recommendation.result} a`) + .attributes?.href?.value; + cy.get(integration?.selectors?.recommendation.activeSlide) + .click({ multiple: true }) + .then(() => { + cy.location('pathname').should('include', url); + }); }); + }); }); }); }); diff --git a/packages/snap-preact-demo/tests/cypress/e2e/recommendation/recommendationBundle.cy.js b/packages/snap-preact-demo/tests/cypress/e2e/recommendation/recommendationBundle.cy.js index 3820ff733..7d5e40a97 100644 --- a/packages/snap-preact-demo/tests/cypress/e2e/recommendation/recommendationBundle.cy.js +++ b/packages/snap-preact-demo/tests/cypress/e2e/recommendation/recommendationBundle.cy.js @@ -46,7 +46,7 @@ describe('BundledRecommendations', () => { describe('Tests Bundle', () => { it('has a controller', function () { cy.snapController(config?.selectors?.recommendation.controller).then(({ store }) => { - expect(store.config.globals.product.length).to.greaterThan(0); + expect(store.config.globals.products.length).to.greaterThan(0); }); }); diff --git a/packages/snap-preact/src/Instantiators/RecommendationInstantiator.test.tsx b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.test.tsx index 716c2f3b2..a4afa9d04 100644 --- a/packages/snap-preact/src/Instantiators/RecommendationInstantiator.test.tsx +++ b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.test.tsx @@ -280,6 +280,7 @@ describe('RecommendationInstantiator', () => { document.body.innerHTML = ` + `; + + const client = new MockClient(baseConfig.client!.globals, {}); + const clientSpy = jest.spyOn(client, 'recommend'); + + const recommendationInstantiator = new RecommendationInstantiator(baseConfig, { client }); + await wait(); + expect(Object.keys(recommendationInstantiator.controller).length).toBe(2); + Object.keys(recommendationInstantiator.controller).forEach((controllerId, index) => { + const controller = recommendationInstantiator.controller[controllerId]; + expect(controller.context).toStrictEqual({ + custom: { some: 'thing' }, + globals: { + products: ['C-AD-W1-1869P'], + shopper: { id: 'snapdev' }, + blockedItems: ['1234', '5678'], + cart: ['5678'], + }, + profile: profileContextArray[index], + }); + }); + const batchId = recommendationInstantiator.controller[Object.keys(recommendationInstantiator.controller)[0]].store.config.batchId; + + expect(clientSpy).toHaveBeenCalledTimes(2); + expect(clientSpy).toHaveBeenNthCalledWith(1, { + batched: true, + blockedItems: ['1234', '5678'], + branch: 'production', + brands: ['12345'], + cart: ['5678'], + categories: ['1234'], + limit: 1, + filters: [ + { + field: 'price', + type: 'range', + value: { + low: 20, + high: 40, + }, + }, + ], + products: ['C-AD-W1-1869P'], + shopper: 'snapdev', + batchId, + siteId: '8uyt2m', + tag: 'trending', + }); + + expect(clientSpy).toHaveBeenNthCalledWith(2, { + batched: true, + blockedItems: ['1234', '5678'], + branch: 'production', + brands: ['65432'], + cart: ['5678'], + categories: ['5678'], + limit: 2, + filters: [ + { + field: 'color', + type: 'value', + value: 'blue', + }, + ], + products: ['C-AD-W1-1869P'], + shopper: 'snapdev', + batchId, + siteId: undefined, + tag: 'similar', + }); + }); + it('will utilize attachments (plugins / middleware) added via methods upon creation of controller', async () => { document.body.innerHTML = ``; diff --git a/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx index a760a60bc..e6779a52b 100644 --- a/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx +++ b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx @@ -6,9 +6,15 @@ import { Client } from '@searchspring/snap-client'; import { Logger } from '@searchspring/snap-logger'; import { Tracker } from '@searchspring/snap-tracker'; -import type { ClientConfig, ClientGlobals } from '@searchspring/snap-client'; +import type { ClientConfig, ClientGlobals, RecommendRequestModel } from '@searchspring/snap-client'; import type { UrlTranslatorConfig } from '@searchspring/snap-url-manager'; -import type { AbstractController, RecommendationController, Attachments, ContextVariables } from '@searchspring/snap-controller'; +import type { + AbstractController, + RecommendationController, + Attachments, + ContextVariables, + RecommendationControllerConfig, +} from '@searchspring/snap-controller'; import type { VariantConfig } from '@searchspring/snap-store-mobx'; import type { Middleware } from '@searchspring/snap-event-manager'; import type { Target } from '@searchspring/snap-toolbox'; @@ -40,6 +46,31 @@ export type RecommendationInstantiatorServices = { tracker?: Tracker; }; +type RecommendationProfileCounts = { + [key: string]: number; +}; + +type ProfileSpecificProfile = { + custom?: any; + options: Pick & { + realtime?: boolean; + }; + profile: string; + target: string; +}; + +type ProfileSpecificGlobals = { + blockedItems: string[]; + cart?: string[] | (() => string[]); + products?: string[]; + shopper?: { id?: string }; + siteId?: string; +}; + +type ExtendedRecommendaitonProfileTarget = Target & { + profile?: ProfileSpecificProfile; +}; + export class RecommendationInstantiator { private mode = AppMode.production; public client: Client; @@ -52,9 +83,9 @@ export class RecommendationInstantiator { public context: ContextVariables; public targeter: DomTargeter; - private uses: Attachments[] = []; - private plugins: { func: (cntrlr: AbstractController, ...args: any) => Promise; args: unknown[] }[] = []; - private middleware: { event: string; func: Middleware[] }[] = []; + public uses: Attachments[] = []; + public plugins: { func: (cntrlr: AbstractController, ...args: any) => Promise; args: unknown[] }[] = []; + public middleware: { event: string; func: Middleware[] }[] = []; constructor(config: RecommendationInstantiatorConfig, services?: RecommendationInstantiatorServices, context?: ContextVariables) { this.config = config; @@ -91,9 +122,7 @@ export class RecommendationInstantiator { this.tracker = services?.tracker || new Tracker(this.config.client!.globals); this.logger = services?.logger || new Logger({ prefix: 'RecommendationInstantiator ', mode: this.mode }); - const profileCount: { - [key: string]: number; - } = {}; + const profileCount: RecommendationProfileCounts = {}; this.targeter = new DomTargeter( [ @@ -112,224 +141,250 @@ export class RecommendationInstantiator { }, }, }, + { + selector: 'script[type="searchspring/recommendations"]', + autoRetarget: true, + clickRetarget: true, + emptyTarget: false, + }, ], - async (target: Target, injectedElem: Element | undefined, elem: Element | undefined) => { - const tag = injectedElem?.getAttribute('searchspring-recommend'); - - if (!tag) { - this.logger.warn(`'profile' attribute is missing from