From 9e919ab8cc73f48276755131541f889be078e186 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 24 Sep 2024 13:35:37 -0600 Subject: [PATCH 1/3] fix(client/recommend): adjusting recommend request to merge and de-dupe global parameters --- docs/INTEGRATION_RECOMMENDATIONS.md | 12 +- .../src/Client/apis/Recommend.test.ts | 199 ++++++++++++++++++ .../snap-client/src/Client/apis/Recommend.ts | 20 +- 3 files changed, 224 insertions(+), 7 deletions(-) diff --git a/docs/INTEGRATION_RECOMMENDATIONS.md b/docs/INTEGRATION_RECOMMENDATIONS.md index 0c3d5668c..ca3ccbcf5 100644 --- a/docs/INTEGRATION_RECOMMENDATIONS.md +++ b/docs/INTEGRATION_RECOMMENDATIONS.md @@ -1,8 +1,11 @@ ## 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 (standard when using Snap object). 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). +Recommendations script blocks can be placed anywhere on the page and will automatically target and batch requests for all profiles specified in the block (requires the `bundle.js` script also). Batching profiles is important for deduplication of recommended products (see more below). + +The block below uses the `recently-viewed` profile which would typically be setup to display the last products viewed by the shopper. Profiles must be setup in the Searchspring Management Console (SMC) and have associated Snap templates selected. ```html + +
``` -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 selector `.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. +The `RecommendationInstantiator` will look for these script blocks on the page and attempt to inject components based on the `selector` specified in each profile. In the example above, the profile specified is the `recently-viewed` profile, and is set to render inside the `.ss__recs__recently-viewed` element just below the script block. The targeted element could exist anywhere on the page - but it is recommended to group elements with script blocks whenever possible (for easy integration identification). The component to render into the targeted `selector` is setup within the `RecommendationInstantiator` configuration. ## Recommendation Context Variables -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. +Context variables are set within the script blocks and can be used to set either global or per profile (profile specific) functionality. Variables are used to alter the results displayed by our recommendations and may be required depending on the profile placements in use. ### Globals Variables | Option | Value | Placement | Description | Required @@ -47,6 +52,7 @@ Context variables are applied to individual recommendation profiles similar to h | 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.blockedItems | array of strings | all | SKU values to identify which products to exclude from the profile response | | | 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 | | diff --git a/packages/snap-client/src/Client/apis/Recommend.test.ts b/packages/snap-client/src/Client/apis/Recommend.test.ts index 32bda6af4..ed0c44006 100644 --- a/packages/snap-client/src/Client/apis/Recommend.test.ts +++ b/packages/snap-client/src/Client/apis/Recommend.test.ts @@ -625,6 +625,113 @@ describe('Recommend Api', () => { requestMock.mockReset(); }); + it('batchRecommendations deduplicates certain global request parameters', async () => { + const api = new RecommendAPI(new ApiConfiguration(apiConfig)); + + //now consts try a post + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + + body: JSON.stringify({ + profiles: [ + { tag: 'profile1' }, + { tag: 'profile2', filters: [{ field: 'size', type: '=', values: ['small'] }] }, + { tag: 'profile3', blockedItems: ['sku3p'] }, + ], + siteId: '8uyt2m', + products: ['product1', 'product2', 'product3', 'product4'], + blockedItems: ['sku1', 'sku3', 'sku4', 'sku2'], + filters: [ + { field: 'color', type: '=', values: ['blue'] }, + { field: 'price', type: '>=', values: [0] }, + { field: 'price', type: '<=', values: [20] }, + { field: 'price', type: '<=', values: [40] }, + { field: 'color', type: '=', values: ['green'] }, + ], + }), + }; + + const POSTRequestUrl = 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend'; + + const POSTRequestMock = jest + .spyOn(global.window, 'fetch') + .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve({}) } as Response)); + + // profile 1 + api.batchRecommendations({ + tag: 'profile1', + siteId: '8uyt2m', + products: ['product1'], + blockedItems: ['sku1'], + filters: [ + { + type: 'value', + field: 'color', + value: 'blue', + }, + { + type: 'range', + field: 'price', + value: { low: 0, high: 20 }, + }, + ], + batched: true, + }); + + // profile 2 + api.batchRecommendations({ + tag: 'profile2', + profile: { + filters: [ + { + type: 'value', + field: 'size', + value: 'small', + }, + ], + }, + siteId: '8uyt2m', + products: ['product2', 'product3'], + blockedItems: ['sku3', 'sku4'], + filters: [ + { + type: 'range', + field: 'price', + value: { low: 0, high: 40 }, + }, + ], + batched: true, + }); + + // profile 3 + api.batchRecommendations({ + tag: 'profile3', + profile: { + blockedItems: ['sku3p'], + }, + siteId: '8uyt2m', + product: 'product4', + blockedItems: ['sku2', 'sku3'], + filters: [ + { + type: 'value', + field: 'color', + value: 'green', + }, + ], + batched: true, + }); + + //add delay for paramBatch.timeout + await wait(250); + + expect(POSTRequestMock).toHaveBeenCalledWith(POSTRequestUrl, POSTParams); + POSTRequestMock.mockReset(); + }); + it('batchRecommendations handles POST requests', async () => { const api = new RecommendAPI(new ApiConfiguration(apiConfig)); @@ -643,6 +750,7 @@ describe('Recommend Api', () => { }), siteId: '8uyt2m', products: ['marnie-runner-2-7x10'], + blockedItems: ['sku1', 'sku2', 'sku3'], filters: [ { field: 'color', type: '=', values: ['blue'] }, { field: 'price', type: '>=', values: [0] }, @@ -678,6 +786,97 @@ describe('Recommend Api', () => { api.batchRecommendations({ tag: i.toString(), ...batchParams, + blockedItems: ['sku1', 'sku2', 'sku3'], + filters: [ + { + type: 'value', + field: 'color', + value: 'blue', + }, + { + type: 'range', + field: 'price', + value: { low: 0, high: 20 }, + }, + ], + batched: true, + }); + } + + //add delay for paramBatch.timeout + await wait(250); + + expect(POSTRequestMock).toHaveBeenCalledWith(POSTRequestUrl, POSTParams); + POSTRequestMock.mockReset(); + }); + + it('batchRecommendations handles POST requests with profile specific request parameters', async () => { + const api = new RecommendAPI(new ApiConfiguration(apiConfig)); + + //now consts try a post + const POSTParams = { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + + body: JSON.stringify({ + profiles: Array.from({ length: 100 }, (item, index) => { + return { + tag: index.toString(), + blockedItems: ['skuBlocked', `sku${index}`], + filters: [{ field: 'color', type: '=', values: ['red'] }], + }; + }), + siteId: '8uyt2m', + products: ['marnie-runner-2-7x10'], + blockedItems: ['sku1', 'sku2', 'sku3'], + filters: [ + { field: 'color', type: '=', values: ['blue'] }, + { field: 'price', type: '>=', values: [0] }, + { field: 'price', type: '<=', values: [20] }, + ], + 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', + ], + }), + }; + + const POSTRequestUrl = 'https://8uyt2m.a.searchspring.io/boost/8uyt2m/recommend'; + + const POSTRequestMock = jest + .spyOn(global.window, 'fetch') + .mockImplementation(() => Promise.resolve({ status: 200, json: () => Promise.resolve({}) } as Response)); + + for (let i = 0; i < 100; i++) { + api.batchRecommendations({ + tag: i.toString(), + profile: { + blockedItems: ['skuBlocked', `sku${i}`], + filters: [ + { + type: 'value', + field: 'color', + value: 'red', + }, + ], + }, + ...batchParams, + blockedItems: ['sku1', 'sku2', 'sku3'], filters: [ { type: 'value', diff --git a/packages/snap-client/src/Client/apis/Recommend.ts b/packages/snap-client/src/Client/apis/Recommend.ts index 475e4298b..1269d760b 100644 --- a/packages/snap-client/src/Client/apis/Recommend.ts +++ b/packages/snap-client/src/Client/apis/Recommend.ts @@ -1,5 +1,5 @@ import { API, ApiConfiguration } from './Abstract'; -import { HTTPHeaders, RecommendPostRequestProfileModel } 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'; @@ -126,13 +126,25 @@ export class RecommendAPI extends API { // parameters used globally const { products, blockedItems, filters, test, cart, lastViewed, shopper } = entry.request; + + // merge and de-dupe global array fields + const dedupedProducts = [...new Set(([] as string[]).concat(batch.request.products || [], products || []))]; + const dedupedBlockedItems = [...new Set(([] as string[]).concat(batch.request.blockedItems || [], blockedItems || []))]; + const dedupedFilters = [ + ...new Set( + ([] as RecommendPostRequestFiltersModel[]) + .concat(batch.request.filters || [], transformRecommendationFiltersPost(filters) || []) + .map((filter) => JSON.stringify(filter)) + ), + ].map((stringyFilter) => JSON.parse(stringyFilter)); + batch.request = { ...batch.request, ...defined({ siteId: entry.request.profile?.siteId || entry.request.siteId, - products, - blockedItems, - filters: transformRecommendationFiltersPost(filters), + products: dedupedProducts.length ? dedupedProducts : undefined, + blockedItems: dedupedBlockedItems.length ? dedupedBlockedItems : undefined, + filters: dedupedFilters.length ? dedupedFilters : undefined, test, cart, lastViewed, From 079e20aefcf89a3c721d3d70d27b36fc3de7a8c3 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 24 Sep 2024 15:36:35 -0600 Subject: [PATCH 2/3] refactor(client/recommend): moving from spread operator to Array.from for build --- packages/snap-client/src/Client/apis/Recommend.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/snap-client/src/Client/apis/Recommend.ts b/packages/snap-client/src/Client/apis/Recommend.ts index 1269d760b..48788ea55 100644 --- a/packages/snap-client/src/Client/apis/Recommend.ts +++ b/packages/snap-client/src/Client/apis/Recommend.ts @@ -128,15 +128,15 @@ export class RecommendAPI extends API { const { products, blockedItems, filters, test, cart, lastViewed, shopper } = entry.request; // merge and de-dupe global array fields - const dedupedProducts = [...new Set(([] as string[]).concat(batch.request.products || [], products || []))]; - const dedupedBlockedItems = [...new Set(([] as string[]).concat(batch.request.blockedItems || [], blockedItems || []))]; - const dedupedFilters = [ - ...new Set( + const dedupedProducts = Array.from(new Set(([] as string[]).concat(batch.request.products || [], products || []))); + const dedupedBlockedItems = Array.from(([] as string[]).concat(batch.request.blockedItems || [], blockedItems || [])); + const dedupedFilters = Array.from( + new Set( ([] as RecommendPostRequestFiltersModel[]) .concat(batch.request.filters || [], transformRecommendationFiltersPost(filters) || []) .map((filter) => JSON.stringify(filter)) - ), - ].map((stringyFilter) => JSON.parse(stringyFilter)); + ) + ).map((stringyFilter) => JSON.parse(stringyFilter)); batch.request = { ...batch.request, From 69152d30eb710816a08c01b24e8efe02472bc111 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 24 Sep 2024 15:46:18 -0600 Subject: [PATCH 3/3] refactor(client/recommend): adjusting dedupe code after refactor (removed Set accidentally) --- packages/snap-client/src/Client/apis/Recommend.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/snap-client/src/Client/apis/Recommend.ts b/packages/snap-client/src/Client/apis/Recommend.ts index 48788ea55..95a20278f 100644 --- a/packages/snap-client/src/Client/apis/Recommend.ts +++ b/packages/snap-client/src/Client/apis/Recommend.ts @@ -1,5 +1,5 @@ import { API, ApiConfiguration } from './Abstract'; -import { HTTPHeaders, RecommendPostRequestFiltersModel, RecommendPostRequestProfileModel } from '../../types'; +import { HTTPHeaders, RecommendPostRequestProfileModel } from '../../types'; import { AppMode } from '@searchspring/snap-toolbox'; import { transformRecommendationFiltersPost } from '../transforms'; import { ProfileRequestModel, ProfileResponseModel, RecommendResponseModel, RecommendRequestModel, RecommendPostRequestModel } from '../../types'; @@ -128,14 +128,10 @@ export class RecommendAPI extends API { const { products, blockedItems, filters, test, cart, lastViewed, shopper } = entry.request; // merge and de-dupe global array fields - const dedupedProducts = Array.from(new Set(([] as string[]).concat(batch.request.products || [], products || []))); - const dedupedBlockedItems = Array.from(([] as string[]).concat(batch.request.blockedItems || [], blockedItems || [])); + const dedupedProducts = Array.from(new Set((batch.request.products || []).concat(products || []))); + const dedupedBlockedItems = Array.from(new Set((batch.request.blockedItems || []).concat(blockedItems || []))); const dedupedFilters = Array.from( - new Set( - ([] as RecommendPostRequestFiltersModel[]) - .concat(batch.request.filters || [], transformRecommendationFiltersPost(filters) || []) - .map((filter) => JSON.stringify(filter)) - ) + new Set((batch.request.filters || []).concat(transformRecommendationFiltersPost(filters) || []).map((filter) => JSON.stringify(filter))) ).map((stringyFilter) => JSON.parse(stringyFilter)); batch.request = {