From 7db133a8926bc4804fdb3b87b04df73e1afe43ce Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 9 Sep 2024 14:57:12 -0600 Subject: [PATCH] refactor(preact/recommendation): rewording docs, variable name changes and slight refactor --- ... => INTEGRATION_LEGACY_RECOMMENDATIONS.md} | 2 +- docs/INTEGRATION_RECOMMENDATIONS.md | 115 ++--- docs/documents.js | 8 + index.html | 3 +- .../src/Client/apis/Legacy.test.ts | 6 +- .../src/Client/apis/Recommend.test.ts | 13 +- .../snap-client/src/Client/apis/Recommend.ts | 15 +- .../src/Client/apis/Suggest.test.ts | 6 +- packages/snap-client/src/types.ts | 6 +- .../RecommendationController.ts | 2 +- packages/snap-preact-demo/public/email.html | 12 +- packages/snap-preact-demo/public/product.html | 14 +- .../public/recommendations.html | 248 ++++++++++ .../e2e/recommendation/recommendation.cy.js | 226 +++++---- .../RecommendationInstantiator.test.tsx | 8 +- .../RecommendationInstantiator.tsx | 453 +++++++++--------- packages/snap-store-mobx/src/types.ts | 2 +- 17 files changed, 725 insertions(+), 414 deletions(-) rename docs/{LEGACY_INTEGRATION_RECOMMENDATIONS.md => INTEGRATION_LEGACY_RECOMMENDATIONS.md} (98%) create mode 100644 packages/snap-preact-demo/public/recommendations.html diff --git a/docs/LEGACY_INTEGRATION_RECOMMENDATIONS.md b/docs/INTEGRATION_LEGACY_RECOMMENDATIONS.md similarity index 98% rename from docs/LEGACY_INTEGRATION_RECOMMENDATIONS.md rename to docs/INTEGRATION_LEGACY_RECOMMENDATIONS.md index cdcbb93ed..51c9b8ec7 100644 --- a/docs/LEGACY_INTEGRATION_RECOMMENDATIONS.md +++ b/docs/INTEGRATION_LEGACY_RECOMMENDATIONS.md @@ -1,6 +1,6 @@ ## Recommendations Integration (Legacy) -For integrations using Snap `v0.60.0` and newer, please reference the updated [`integration docs`](https://searchspring.github.io/snap/#/integration-recommendations). +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). diff --git a/docs/INTEGRATION_RECOMMENDATIONS.md b/docs/INTEGRATION_RECOMMENDATIONS.md index 52f52e782..5312157fa 100644 --- a/docs/INTEGRATION_RECOMMENDATIONS.md +++ b/docs/INTEGRATION_RECOMMENDATIONS.md @@ -1,16 +1,20 @@ ## Recommendations Integration -Changes to the recommendation integration scripts were made in Snap `v.0.60.0`. Legacy Recommmendation Integrations docs can still be found [`here`](https://github.com/searchspring/snap/blob/main/packages/snap-preact/src/Instantiators/README.md) +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 site with a single script block (requires the `bundle.js` script also). +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 `profiles` specified. In the example above, the profile specified is the `recently-viewed` profile, and is set to render inside the target `.above-content`, 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 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. +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 | 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 | -| shopper.id | logged in user unique identifier | all | required for personalization functionallity if not provided to the bundle (global) context | +| Option | Value | Placement | Description | Required +|---|---|:---:|---|:---:| +| 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 | | +| 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 | Page | Description | -|---|---|:---:|---| -| 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.searchTerm | string | all | 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.blockedItems | array of strings | all | SKU values to identify which products to exclude from the response | -| 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) | +| 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.searchTerm | string | all | 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.blockedItems | array of strings | all | SKU values to identify which products to exclude from the response | | +| 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, each script tag will fetch the recommendations for each profile it finds a target for in a single batched request. The priority order is based on the order listed in the profiles array. -In most cases batching everything is the best practice, however for profiles like a mini cart (side cart) de-duplication may not be desired. De-duplication can be turned off per profile with a `dedupe: false` value, or you can add an additional script to fetch the recommendations in a seperate batch. +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. + +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. + +## Deduping -The example below shows how to manually specify the order and batching of specific profiles. +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: ```html - - - ``` @@ -107,7 +108,7 @@ 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. +A typical "similar" profile that would display products similar to the product passed in via the `products` global context variable. ```html @@ -133,7 +134,7 @@ If tracking scripts are not in place, "crosssell" profiles may require the cart profiles = [ { profile: 'customers-also-bought', - target: '.crosssell' + target: '.ss__recs__crosssell' } ]; @@ -151,21 +152,21 @@ If the shopper identifier is not beeing captured by the `bundle.js` context, it profiles = [ { profile: 'view-cart', - target: '.cart' + target: '.ss__recs__cart' } ]; ``` ### 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 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/tests/cypress/e2e/recommendation/recommendation.cy.js b/packages/snap-preact-demo/tests/cypress/e2e/recommendation/recommendation.cy.js index 2f554c662..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.limit).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/src/Instantiators/RecommendationInstantiator.test.tsx b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.test.tsx index 748f390cc..baa7aac61 100644 --- a/packages/snap-preact/src/Instantiators/RecommendationInstantiator.test.tsx +++ b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.test.tsx @@ -355,7 +355,7 @@ describe('RecommendationInstantiator', () => { value: { low: 0, high: 20 }, }, ], - groupId: 1, + batchId: 1, brands: ['nike', 'h&m'], limit: 5, product: 'sku1', @@ -464,7 +464,7 @@ describe('RecommendationInstantiator', () => { ...profileContextArray[index], }); }); - const groupId = recommendationInstantiator.controller[Object.keys(recommendationInstantiator.controller)[0]].store.config.groupId; + const batchId = recommendationInstantiator.controller[Object.keys(recommendationInstantiator.controller)[0]].store.config.batchId; expect(clientSpy).toHaveBeenCalledTimes(2); expect(clientSpy).toHaveBeenNthCalledWith(1, { @@ -482,7 +482,7 @@ describe('RecommendationInstantiator', () => { value: 'red', }, ], - groupId, + batchId, siteId: '8uyt2m', tag: 'trending', }); @@ -502,7 +502,7 @@ describe('RecommendationInstantiator', () => { value: 'blue', }, ], - groupId, + batchId, siteId: '8uyt2m', tag: 'similar', }); diff --git a/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx index 4985b3477..b0eefc8fb 100644 --- a/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx +++ b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx @@ -6,7 +6,7 @@ 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, @@ -46,10 +46,27 @@ export type RecommendationInstantiatorServices = { tracker?: Tracker; }; -type IProfileCount = { +type RecommendationProfileCounts = { [key: string]: number; }; +type ProfileSpecificProfile = { + profile: string; + target: string; + options: Partial; +}; + +type ProfileSpecificGlobals = { + products?: string[]; + siteId?: string; + cart?: string[] | (() => string[]); + shopper?: { id?: string }; +}; + +type ExtendedRecommendaitonTarget = Target & { + context?: ProfileSpecificProfile; +}; + export class RecommendationInstantiator { private mode = AppMode.production; public client: Client; @@ -62,9 +79,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; @@ -101,7 +118,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: IProfileCount = {}; + const profileCount: RecommendationProfileCounts = {}; this.targeter = new DomTargeter( [ @@ -127,73 +144,76 @@ export class RecommendationInstantiator { emptyTarget: false, }, ], - async (target: Target, injectedElem: Element | undefined, elem: Element | undefined) => { + async (target: Target, elem: Element | undefined, originalElem: Element | undefined) => { const elemContext = getContext( ['shopperId', 'shopper', 'product', 'products', 'seed', 'cart', 'options', 'profile', 'profiles', 'globals', 'custom'], - (elem || injectedElem) as HTMLScriptElement + (originalElem || elem) as HTMLScriptElement ); const context: ContextVariables = deepmerge(this.context, elemContext); - const { profiles, globals } = context; + const profiles = context.profiles as ProfileSpecificProfile[]; + const globals = context.globals as ProfileSpecificGlobals; // controller globals and shared things if (profiles && profiles.length) { - const groupId = Math.random(); - - profiles.forEach((_profile: any) => { - const targetsArr = []; - const { target } = _profile; - const targetObj = { - selector: target, - autoRetarget: true, - clickRetarget: true, - }; - targetsArr.push(targetObj); - - // FEEDBACK: should this happen after the forEach loop completes? - new DomTargeter(targetsArr, async (__target: Target, __injectedElem: Element | undefined, __elem: Element | undefined) => { - const context: ContextVariables = deepmerge(this.context, { ..._profile, globals }); - - const { options: profileOptions } = context; - const controllerGlobals: any = {}; - - const tag = context.profile; - - //context globals - if (context.globals) { - if (context.globals.siteId) { - controllerGlobals.siteId = context.globals.siteId; - } - if (context.globals.products) { - controllerGlobals.products = context.globals.products; - } - if (context.globals.blockedItems) { - controllerGlobals.blockedItems = context.globals.blockedItems; - } - if (context.globals.cart) { - controllerGlobals.cart = context.globals.cart; - } - } + const targetsArr: ExtendedRecommendaitonTarget[] = []; + const batchId = Math.random(); + + profiles.forEach((profile) => { + if (profile.target) { + const targetObj = { + selector: profile.target, + autoRetarget: true, + clickRetarget: true, + context: profile, + }; + + targetsArr.push(targetObj); + } + }); - if (profileOptions?.filters) { - controllerGlobals.profileFilters = profileOptions.filters; - } + new DomTargeter(targetsArr, async (target: ExtendedRecommendaitonTarget, elem: Element | undefined, originalElem: Element | undefined) => { + const profileContext: ContextVariables = deepmerge(this.context, { ...target.context, globals }); + + const { options: profileOptions } = profileContext; + const controllerGlobals: Partial = {}; + + const tag = profileContext.profile; - if (typeof profileOptions?.dedupe == 'boolean') { - controllerGlobals.dedupe = profileOptions.dedupe; + // context globals + if (profileContext.globals) { + if (profileContext.globals.siteId) { + controllerGlobals.siteId = profileContext.globals.siteId; } + if (profileContext.globals.products) { + controllerGlobals.products = profileContext.globals.products; + } + if (profileContext.globals.blockedItems) { + controllerGlobals.blockedItems = profileContext.globals.blockedItems; + } + if (profileContext.globals.cart) { + controllerGlobals.cart = profileContext.globals.cart; + } + } + + if (profileOptions?.filters) { + controllerGlobals.profileFilters = profileOptions.filters; + } + + if (typeof profileOptions?.dedupe == 'boolean') { + controllerGlobals.dedupe = profileOptions.dedupe; + } - this.readyTheController(__injectedElem, context, profileCount, __elem, groupId, controllerGlobals, tag); - }); + readyTheController(this, elem, profileContext, profileCount, originalElem, batchId, controllerGlobals, tag); }); } else { const { product, seed, options } = context; const controllerGlobals: any = {}; - const tag = injectedElem?.getAttribute('searchspring-recommend'); + const tag = elem?.getAttribute('searchspring-recommend'); if (product || seed) { controllerGlobals.product = product || seed; @@ -211,196 +231,197 @@ export class RecommendationInstantiator { controllerGlobals.filters = options.filters; } - this.readyTheController(injectedElem, context, profileCount, elem, 1, controllerGlobals, tag); + readyTheController(this, elem, context, profileCount, originalElem, 1, controllerGlobals, tag); } } ); } - private readyTheController = async ( - injectedElem: Element | undefined, - context: ContextVariables, - profileCount: IProfileCount, - elem: Element | undefined, - groupId: number, - controllerGlobals: Partial, - tag: string | null | undefined - ) => { - const { shopper, shopperId, products, cart, options } = context; - const blockedItems = context.options?.blockedItems; - - if (!tag) { - // FEEDBACK: change message depending on script integration type (profile vs. legacy) - this.logger.warn(`'profile' attribute is missing from