Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release v0.60.2 #1171

Merged
merged 10 commits into from
Sep 24, 2024
Merged
12 changes: 9 additions & 3 deletions docs/INTEGRATION_RECOMMENDATIONS.md
Original file line number Diff line number Diff line change
@@ -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
<script type="searchspring/recommendations">
Expand All @@ -21,13 +24,15 @@ It is recommended to utilize the [`RecommendationInstantiator`](https://github.c
}
];
</script>

<div class="ss__recs__recently-viewed"><!-- recommendations will render here --></div>
```

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
Expand All @@ -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 | |
Expand Down
199 changes: 199 additions & 0 deletions packages/snap-client/src/Client/apis/Recommend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -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] },
Expand Down Expand Up @@ -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',
Expand Down
14 changes: 11 additions & 3 deletions packages/snap-client/src/Client/apis/Recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,21 @@ 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 = 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((batch.request.filters || []).concat(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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { h } from 'preact';

import { render } from '@testing-library/preact';
import { render, waitFor } from '@testing-library/preact';
import userEvent from '@testing-library/user-event';

import { Checkbox } from './Checkbox';
Expand Down Expand Up @@ -72,6 +72,20 @@ describe('Checkbox Component', () => {
expect(clickFn).toHaveBeenCalled();
});

it('adjusts aria attributes', async () => {
const rendered = render(<Checkbox />);

const checkboxElement = rendered.container.querySelector('.ss__checkbox')!;

expect(checkboxElement.getAttribute('aria-checked')).toBe('false');

userEvent.click(checkboxElement);

await waitFor(() => {
expect(checkboxElement.getAttribute('aria-checked')).toBe('true');
});
});

it('renders with additional style using prop', () => {
const style = {
padding: '20px',
Expand Down Expand Up @@ -104,6 +118,7 @@ describe('Checkbox Component', () => {
const rendered = render(<Checkbox disabled onClick={clickFn} />);
const checkboxElement = rendered.container.querySelector('.ss__checkbox');

expect(checkboxElement?.getAttribute('aria-disabled')).toBe('true');
expect(checkboxElement?.className.match(/disabled/)).toBeTruthy();
if (checkboxElement) userEvent.click(checkboxElement);
expect(clickFn).not.toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const Checkbox = observer((properties: CheckboxProps): JSX.Element => {
{...styling}
className={classnames('ss__checkbox', { 'ss__checkbox--disabled': disabled }, className)}
type="checkbox"
aria-checked={checkedState}
onClick={(e) => clickFunc(e)}
disabled={disabled}
checked={checkedState}
Expand All @@ -122,8 +123,9 @@ export const Checkbox = observer((properties: CheckboxProps): JSX.Element => {
className={classnames('ss__checkbox', { 'ss__checkbox--disabled': disabled }, className)}
onClick={(e) => clickFunc(e)}
ref={(e) => (!disableA11y ? useA11y(e) : null)}
aria-label={`${disabled ? 'disabled' : ''} ${checkedState ? 'checked' : 'unchecked'} checkbox`}
aria-disabled={disabled}
role="checkbox"
aria-checked={checkedState}
>
{checkedState ? <Icon {...subProps.icon} /> : <span className="ss__checkbox__empty" />}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ describe('List Component', () => {
expect(optionElements).toBeInTheDocument();

expect(optionElements.innerHTML).toBe(
`<span class=\"ss__checkbox ss-v51376-I\" aria-label=\" unchecked checkbox\" role=\"checkbox\"><span class=\"ss__checkbox__empty\"></span></span>`
`<span class=\"ss__checkbox ss-v51376-I\" role=\"checkbox\" aria-checked=\"false\"><span class=\"ss__checkbox__empty\"></span></span>`
);

await userEvent.click(optionElements);
Expand Down
8 changes: 4 additions & 4 deletions packages/snap-tracker/src/Tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ export class Tracker {
const sku = data?.childSku || data?.childUid || data?.sku || data?.uid;
if (sku) {
const lastViewedProducts = this.cookies.viewed.get();
const uniqueCartItems = Array.from(new Set([...lastViewedProducts, sku])).map((item) => item.trim());
const uniqueCartItems = Array.from(new Set([...lastViewedProducts, sku])).map((item) => `${item}`.trim());
cookies.set(
VIEWED_PRODUCTS,
uniqueCartItems.slice(0, MAX_VIEWED_COUNT).join(','),
Expand Down Expand Up @@ -688,7 +688,7 @@ export class Tracker {
},
set: (items: string[]): void => {
if (items.length) {
const cartItems = items.map((item) => item.trim());
const cartItems = items.map((item) => `${item}`.trim());
const uniqueCartItems = Array.from(new Set(cartItems));
cookies.set(CART_PRODUCTS, uniqueCartItems.join(','), COOKIE_SAMESITE, 0, COOKIE_DOMAIN);

Expand All @@ -701,7 +701,7 @@ export class Tracker {
add: (items: string[]): void => {
if (items.length) {
const currentCartItems = this.cookies.cart.get();
const itemsToAdd = items.map((item) => item.trim());
const itemsToAdd = items.map((item) => `${item}`.trim());
const uniqueCartItems = Array.from(new Set([...currentCartItems, ...itemsToAdd]));
cookies.set(CART_PRODUCTS, uniqueCartItems.join(','), COOKIE_SAMESITE, 0, COOKIE_DOMAIN);

Expand All @@ -714,7 +714,7 @@ export class Tracker {
remove: (items: string[]): void => {
if (items.length) {
const currentCartItems = this.cookies.cart.get();
const itemsToRemove = items.map((item) => item.trim());
const itemsToRemove = items.map((item) => `${item}`.trim());
const updatedItems = currentCartItems.filter((item) => !itemsToRemove.includes(item));
cookies.set(CART_PRODUCTS, updatedItems.join(','), COOKIE_SAMESITE, 0, COOKIE_DOMAIN);

Expand Down
Loading