diff --git a/docs/PREACT_BADGES.md b/docs/PREACT_BADGES.md
new file mode 100644
index 000000000..90ef548ef
--- /dev/null
+++ b/docs/PREACT_BADGES.md
@@ -0,0 +1,364 @@
+## Badges
+
+Badges are self-configured in the Searchspring Management Console
+
+To displays badges the Result card must include the [OverlayBadge](https://searchspring.github.io/snap/#/components-preact?params=%3Fpath%3D%2Fstory%2Fmolecules-overlaybadge--default) and [CalloutBadge](https://searchspring.github.io/snap/#/components-preact?params=%3Fpath%3D%2Fstory%2Fmolecules-calloutbadge--default) components
+
+
+### OverlayBadge
+
+The `OverlayBadge` component wraps elements (children) that should have badges overlayed - typically the product image
+
+```jsx
+
+
+
+```
+
+### CalloutBadge
+
+The `CalloutBadge` component displays badges inline and can be placed in any position in the Result card
+
+```jsx
+
+```
+
+### Badge Components
+The `OverlayBadge` and `CalloutBadge` components are responsible for displaying badges
+
+The default badges available:
+
+- [BadgePill](https://searchspring.github.io/snap/#/components-preact?params=%3Fpath%3D%2Fstory%2Fatoms-badgepill--default)
+- [BadgeText](https://searchspring.github.io/snap/#/components-preact?params=%3Fpath%3D%2Fstory%2Fatoms-badgetext--default)
+- [BadgeRectangle](https://searchspring.github.io/snap/#/components-preact?params=%3Fpath%3D%2Fstory%2Fatoms-badgerectangle--default)
+- [BadgeImage](https://searchspring.github.io/snap/#/components-preact?params=%3Fpath%3D%2Fstory%2Fatoms-badgeimage--default)
+
+
+## Custom Badge Templates
+
+Custom Badge Templates can be created and sync to the Searchspring Management Console using the Snapfu CLI. See [Getting Started > Setup](https://searchspring.github.io/snap/#/start-setup) for installing Snapfu
+
+### Initialize Custom Badges
+
+First we'll initialize a new custom badge. The code examples on this page will use a `[badgename]` of `CustomBadge`
+
+```sh
+snapfu badges init [badgename]
+```
+This will create two files:
+
+- `src/components/Badges/[badgename]/[badgename].jsx` - The jsx file is the badge component itself that will be displayed by `OverlayBadge` and `CalloutBadge`. The badge `tag`, `value`, and template `parameters` will be passed down as props. If badge template parameters are going to be modifying css we recommend using `@emotion/react`, otherwise this can be removed
+
+```jsx
+import { css } from '@emotion/react';
+import { observer } from 'mobx-react';
+
+// css in js styling using dynamic template parameters
+const CSS = {
+ Custom: (parameters) => {
+ // const { bg_color } = parameters;
+ return css({
+ // background: bg_color
+ });
+ }
+};
+
+export const CustomBadge = observer((props) => {
+
+ const { tag, value, parameters } = props;
+
+ return (
+
{ value }
+ )
+});
+```
+
+- `src/components/Badges/[badgename]/[badgename].json` - The json file describes the badge template and its parameters. See `Badge Template Parameters` section below for possible parameters
+
+```json
+{
+ "type": "snap/badge/default",
+ "name": "custombadge",
+ "label": "CustomBadge Badge",
+ "description": "custombadge custom template",
+ "component": "CustomBadge",
+ "locations": [
+ "left",
+ "right",
+ "callout"
+ ],
+ "value": {
+ "enabled": true
+ },
+ "parameters": []
+}
+```
+
+### Syncing Custom Badges
+
+Next we'll sync our custom badge - registering it to the Searchspring Management Console
+
+```sh
+snapfu badges sync [badgename]
+```
+
+### Using Custom Badges
+
+Finally in order to use a custom badge component, we'll need to provide a `componentMap` prop containing a mapping of our custom components to the `OverlayBadge` and `CalloutBadge` components
+
+**Note:** This is not required if using the default selection of badges
+
+```jsx
+import { CustomBadge } from './components/Badges/CustomBadge';
+
+ CustomBadge
+ }}
+>
+
+
+
+ CustomBadge
+ }}
+/>
+```
+
+The `componentMap` prop can also be used to overwrite the default badge components without the need of initializing and syncing a dedicated custom component
+
+### Badge Template Overview (JSON file)
+
+#### Required:
+
+`type` - should not be changed. It is utilized by the Snapfu CLI when syncing
+
+`name` - unique badge template identifier
+
+`label` - label that is displayed when selecting this badge template within the Searchspring Management Console
+
+`description` - badge template description
+
+`component` - component name this badge template should use. It should line up with the mapping provided to the `componentMap` props. See `Using Custom Badges` section above
+
+`locations` - a list of template locations this badge template can be placed in. This can be used to restrict certain badges to certain locations. See `Custom Badge Locations` section below for adding locations. See `Badge Template Locations` section below for possible values
+
+`parameters` - a list of badge template parameters. Can be an empty array to not contain template parameters. See `Badge Template Parameters` section below for possible parameters
+
+#### Optional:
+
+`value.enabled` - boolean that when true, required a badge `value` to be provided when using this template
+
+`value.validations.min` - ensures `value` meets a numerical minimum or string length
+
+`value.validations.max` - ensures `value` meets a numerical maximum or string length
+
+`value.validations.regex` - ensures `value` meets a regex definition. Must also provide `value.validations.regexExplain`
+
+`value.validations.regexExplain` - required if using `value.validations.regex`. Describes the regex definition and is displayed as an error message if the regex validation fails
+
+
+### Badge Template Locations
+Badge template locations is an array of strings
+
+Possible values when using default locations: `left`, `left/left`, `right`, `right/right`, `callout`, `callout/callout`
+
+Possible values when using **custom** locations: `left`, `left/[tag]`, `right`, `right/[tag]`, `callout`, `callout/[tag]`. See `Custom Badge Locations` section below for creating custom locations
+
+For example, if the locations.json file contains the following location definition:
+
+```json
+{
+ "left": [
+ {
+ "tag": "left",
+ "name": "Top Left"
+ },
+ {
+ "tag": "left-bottom",
+ "name": "Bottom Left"
+ }
+ ]
+}
+```
+
+To restrict a badge template to a custom location, the badge template `locations` array should contain the `tag` of the locations. Ie. `left/left-bottom`
+
+
+### Badge Template Parameters
+Badge template parameters is an array of objects. Each object is a template parameter and contains the following properties:
+
+#### Required:
+
+`name` - unique badge parameter identifier
+
+`type` - parameter value type. Available types: `array`, `string`, `color`, `url`, `integer`, `decimal`, `boolean`, `checkbox`, `toggle`. See example below for example usage of each type
+
+`label` - label that is displayed when selecting this badge parameter within the Searchspring Management Console
+
+`description` - badge parameter description
+
+`options` - required only if `type` is `array`. Define an array of strings containing dropdown value options
+
+
+#### Optional:
+
+`defaultValue` - default value that will be used unless specified when configuring a new badge rule. Must be a string regardless of different `type` options
+
+`validations` - only applicable if `type` is `string`, `url`. `integer`, `decimal`
+
+`validations.min` - only applicable if `type` is `integer`, `decimal`, `string`, `url`. Should be a number (negative values also accepted)
+
+- If `type` is `integer` or `decimal`, ensures `defaultValue` or the user defined `value` meets a **numerical minimum**
+
+- If `type` is `string` or `url`, ensures `defaultValue` or the user defined `value` meets a minimum **character length**
+
+`validations.max` - only applicable if `type` is `integer`, `decimal`, `string`, `url`. Should be a number (negative values also accepted)
+
+- If `type` is `integer` or `decimal`, ensures `defaultValue` or the user defined `value` meets a **numerical maximum**
+
+- If `type` is `string` or `url`, ensures `defaultValue` or the user defined `value` meets a maximum **character length**
+
+`validations.regex` - ensures `defaultValue` or the user defined `value` meets a regex definition. Must also provide `validations.regexExplain`
+
+`validations.regexExplain` - required if using `validations.regex`. Describes the regex definition and is displayed as an error message if the regex validation fails
+
+
+```json
+{
+ "parameters": [
+ {
+ "name": "font_size",
+ "type": "array",
+ "label": "Font Size",
+ "description": "Select the badge font size",
+ "defaultValue": "14px",
+ "options": ["14px", "16px", "18px", "20px", "22px", "24px", "26px", "28px", "30px"]
+ },
+ {
+ "name": "prefix",
+ "type": "string",
+ "label": "Prefix Symbol",
+ "description": "Display a prefix before the badge value. Ie. currency symbol",
+ "validations": {
+ "regex": "^[$€]$",
+ "regexExplain": "Only $ or € currency symbols are allowed"
+ }
+ },
+ {
+ "name": "bg_color",
+ "type": "color",
+ "label": "Badge background color",
+ "description": "Select the badge background color",
+ "defaultValue": "rgba(0,0,255,1.0)"
+ },
+ {
+ "name": "link",
+ "type": "url",
+ "label": "Redirect to URL on click",
+ "description": "Redirect to a URL when the badge is clicked"
+ },
+ {
+ "name": "zindex",
+ "type": "integer",
+ "label": "Z-index",
+ "description": "Set a z-index value for the badge",
+ "validations": {
+ "min": -1,
+ "max": 2147483647
+ }
+ },
+ {
+ "name": "opacity",
+ "type": "decimal",
+ "label": "Opacity",
+ "description": "Badge opacity value between 0 and 1.0",
+ "defaultValue": "1.0",
+ "validations": {
+ "min": 0,
+ "max": 1
+ }
+ },
+ {
+ "name": "bold_value",
+ "type": "boolean",
+ "label": "Make value bold",
+ "description": "Should the badge value be bold text?",
+ "defaultValue": "false"
+ },
+ {
+ "name": "show_border",
+ "type": "checkbox",
+ "label": "Show border",
+ "description": "Display a border around the badge",
+ "defaultValue": "false"
+ },
+ {
+ "name": "show_shadow",
+ "type": "toggle",
+ "label": "Show shadow",
+ "description": "Display a shadow behind the badge",
+ "defaultValue": "true"
+ }
+ ]
+}
+```
+
+## Custom Badge Locations
+
+Custom Badge Locations can be created and synced to the Searchspring Management Console using the Snapfu CLI. See [Getting Started > Setup](https://searchspring.github.io/snap/#/start-setup) for installing Snapfu
+
+Custom overlay and callout locations can be created by defining a `locations.json` file in the project. It is recommended to create it at: `src/components/Badges/locations.json`
+
+`type` - should not be changed. It is utilized by the Snapfu CLI when syncing
+
+`left`, `right`, `callout` - should not be changed and always included
+
+- `left` and `right` define overlay locations used by `OverlayBadge`
+- `callout` define callout locations used by `CalloutBadge`
+
+`['left' | 'right' | 'callout'].tag` - unique badge location identifier
+
+`['left' | 'right' | 'callout'].name` - badge location name that is displayed when selecting this location within the Searchspring Management Console
+
+**important** - it is strongly recommended to keep the default location tags (ie. `left[0].tag="left"`, `right[0].tag="right"`, `callout[0].tag="callout"`) to ensure any existing badges are backwards compatible with additional locations
+
+```json
+{
+ "type": "snap/badge/locations",
+ "left": [
+ {
+ "tag": "left",
+ "name": "Top Left"
+ },
+ {
+ "tag": "left-bottom",
+ "name": "Bottom Left"
+ }
+ ],
+ "right": [
+ {
+ "tag": "right",
+ "name": "Top Right"
+ },
+ {
+ "tag": "right-bottom",
+ "name": "Bottom Right"
+ }
+ ],
+ "callout": [
+ {
+ "tag": "callout",
+ "name": "Callout"
+ },
+ {
+ "tag": "callout_secondary",
+ "name": "Secondary Callout"
+ }
+ ]
+}
+```
\ No newline at end of file
diff --git a/docs/documents.js b/docs/documents.js
index 42a22a364..22d74ff0d 100644
--- a/docs/documents.js
+++ b/docs/documents.js
@@ -70,6 +70,13 @@ var documents = [
url: './docs/PREACT_RECOMMENDATIONS.md',
searchable: true,
},
+ {
+ label: 'Badges',
+ route: '/start-preact-badges',
+ type: 'markdown',
+ url: './docs/PREACT_BADGES.md',
+ searchable: true,
+ },
],
},
{
@@ -318,6 +325,13 @@ var documents = [
url: './packages/snap-store-mobx/src/Search/README.md',
searchable: true,
},
+ {
+ label: 'Meta',
+ route: '/package-storeMobx-meta',
+ type: 'markdown',
+ url: './packages/snap-store-mobx/src/Meta/README.md',
+ searchable: true,
+ },
{
label: 'Storage',
route: '/package-storeMobx-storage',
diff --git a/index.html b/index.html
index 3485a5edb..e2ee9e914 100644
--- a/index.html
+++ b/index.html
@@ -35,6 +35,7 @@
{ a: "(https://github.com/searchspring/snap/blob/main/docs/SEARCH.md)", b: "(#/advanced-search)"},
{ a: "(https://github.com/searchspring/snap/blob/main/docs/PREACT_DISPLAYING_DATA.md)", b: "(#/start-preact-events)"},
+ { a: "(https://github.com/searchspring/snap/blob/main/docs/PREACT_BADGES.md)", b: "(#/start-preact-badges)"},
{ a: "(https://github.com/searchspring/snap/blob/main/docs/INTEGRATION.md)", b: "(#/integration)"},
{ a: "(https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_BACKGROUND_FILTERS.md)", b: "(#/integration-backgroundFilters)"},
{ a: "(https://github.com/searchspring/snap/blob/main/docs/INTEGRATION_CONTEXT.md)", b: "(#/integration-context)"},
@@ -70,6 +71,9 @@
{ a: "(https://github.com/searchspring/snap/tree/main/packages/snap-store-mobx/src/Storage)", b: "(#/package-storeMobx-storage)"},
{ a: "(https://github.com/searchspring/snap/tree/main/packages/snap-store-mobx/src/Search)", b: "(#/package-storeMobx-search)"},
+ { a: "(https://github.com/searchspring/snap/tree/main/packages/snap-store-mobx/src/Autocomplete)", b: "(#/package-storeMobx-autocomplete)"},
+ { a: "(https://github.com/searchspring/snap/tree/main/packages/snap-store-mobx/src/Recommendation)", b: "(#/package-storeMobx-recommendation)"},
+ { a: "(https://github.com/searchspring/snap/tree/main/packages/snap-store-mobx/src/Finder)", b: "(#/package-storeMobx-finder)"},
{ a: "(https://github.com/searchspring/snap/tree/main/packages/snap-store-mobx/src/Abstract)", b: "(#/package-storeMobx-abstract)"},
{ a: "(https://github.com/searchspring/snap/tree/main/packages/snap-controller/src/Abstract)", b: "(#/package-controller-abstract)" },
diff --git a/packages/snap-platforms/bigcommerce/groovy/ss_variants.groovy b/packages/snap-platforms/bigcommerce/groovy/ss_variants.groovy
index 9342475ce..e83e52b18 100644
--- a/packages/snap-platforms/bigcommerce/groovy/ss_variants.groovy
+++ b/packages/snap-platforms/bigcommerce/groovy/ss_variants.groovy
@@ -1,4 +1,4 @@
-/*
+/*
Snap variants script for BigCommerce
Generates a JSON string with format:
@@ -13,55 +13,107 @@
]
Each object (variant) in the array represents a variation of the product and each of the `options` should reflect that configuration.
- When using this in Snap any properties found in `mappings.core` and `attributes` will be "masked" when the variant is selected.
+ When using this in Snap any properties found in `mappings.core` and `attributes` will be "masked" via result.display when the variant is selected.
See Snap documentation for more details.
*/
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
-import org.apache.commons.lang.StringUtils
def slurper = new JsonSlurper()
-def ss_variants = []
-if (Objects.nonNull(doc?.child_sku_options) && !StringUtils.isEmpty(doc?.child_sku_options)) {
+/* map variants_json -> variants structure and put into array */
+def variant_array = []
+
+// variant data to put into core fields
+def core_fields_mapping = [
+ uid: "product_id",
+ price: "price",
+ msrp: "retail_price",
+ sku: "child_sku",
+ imageUrl: 'image_url',
+ thumbnailImageUrl: 'image_url',
+]
+
+// attributes outside of the options
+def attributes_fields_mapping = [
+ quantity: "inventory_level", // property must be named "quantity" for proper functionality
+]
+
+def sku_options_by_id = [:];
+/*
+ sku_options_by_id = {
+ [id: string]: option[], // all options in this array have the same sku_option.id
+ }
+*/
+
+if (doc?.child_sku_options && Objects.nonNull(doc?.child_sku_options)) {
def sku_options = slurper.parseText(doc.child_sku_options as String)
- if(Objects.nonNull(sku_options) && !(sku_options as List).isEmpty()){
+
+ // build out map of sku_options_by_id options - options are grouped together by sku_option.id
+ if(Objects.nonNull(sku_options) && !(sku_options as List).isEmpty()) {
sku_options.each { sku_option ->
- def sku = [:]
- def mappings = [:]
- def core = [:]
- def attributes = [:]
- def option_data = [:]
- def options = [:]
-
- core.put("imageUrl" , sku_option?.image_url)
- core.put("url", doc.url)
- core.put("uid" ,sku_option.child_sku)
- mappings.put("core", core)
- sku.put("mappings",mappings)
-
- if(Objects.nonNull(sku_option?.inventory_level)){
- attributes.put("available", sku_option?.inventory_level > 0)
- }
+ sku_options_by_id[sku_option.id] = sku_options_by_id[sku_option.id] ?: [];
+ sku_options_by_id[sku_option.id].push(sku_option);
+ }
+ }
- if(Objects.nonNull(sku_option?.option) && !StringUtils.isEmpty(sku_option?.option) && Objects.nonNull(sku_option?.value) && !StringUtils.isEmpty(sku_option?.value)){
- attributes.put("title", sku_option?.option + " / " + sku_option?.value)
+ // use sku_options_by_id map to poppulate variant_array
+ sku_options_by_id.each { id, options ->
+ def variant_object = [:]
+ variant_object.mappings = [:]
+ variant_object.mappings.core = [:]
+ variant_object.attributes = [:]
+ variant_object.options = [:]
+ // convert into a variant object
+ /*
+ {
+ "mappings": {
+ "core": { ... }
+ },
+ "attributes": {
+ ...
+ }
}
- sku.put("attributes",attributes)
+ */
- option_data.put("value", sku_option?.value)
+ // loop through each option_array
+ options.each { option ->
+ /* populate core mappings */
+ core_fields_mapping.each { core_field_name, variant_field_name ->
+ if (option[variant_field_name] && Objects.nonNull(option[variant_field_name])) {
+ variant_object.mappings.core[core_field_name] = option[variant_field_name]
+ }
+ }
- if(Objects.nonNull(sku_option?.option)){
- options.put(sku_option?.option, option_data)
+ /* populate attributes */
+ attributes_fields_mapping.each { attribute_field_name, variant_field_name ->
+ if (option[variant_field_name] && Objects.nonNull(option[variant_field_name])) {
+ variant_object.attributes[attribute_field_name] = option[variant_field_name]
+ }
}
- sku.put("options",options)
- ss_variants.add(sku)
+ // determine availability
+ if (option.inventory_level > 0 && !option.purchasing_disabled) {
+ variant_object.attributes.available = true
+ } else {
+ variant_object.attributes.available = false
+ }
+
+ /* populate options */
+ if (option.option && option.value && option.option_id && option.option_value_id) {
+ variant_object.options[option.option] = [
+ value: option.value,
+ optionValue: option.option_value_id,
+ optionId: option.option_id,
+ ]
+ }
}
+
+ variant_array.push(variant_object);
}
}
-index.put("ss_variants", JsonOutput.toJson(ss_variants))
\ No newline at end of file
+index.ss_variants = JsonOutput.toJson(variant_array)
\ No newline at end of file
diff --git a/packages/snap-platforms/bigcommerce/src/addToCart.test.ts b/packages/snap-platforms/bigcommerce/src/addToCart.test.ts
index 86f8f9b0d..155be1f9b 100644
--- a/packages/snap-platforms/bigcommerce/src/addToCart.test.ts
+++ b/packages/snap-platforms/bigcommerce/src/addToCart.test.ts
@@ -1,6 +1,5 @@
import 'whatwg-fetch';
import { addToCart } from './addToCart';
-import { Product } from '@searchspring/snap-store-mobx';
import { MockClient } from '@searchspring/snap-shared';
import { SearchStore } from '@searchspring/snap-store-mobx';
import { UrlManager, QueryStringTranslator, reactLinker } from '@searchspring/snap-url-manager';
@@ -10,9 +9,15 @@ import { Logger } from '@searchspring/snap-logger';
import { Tracker } from '@searchspring/snap-tracker';
import { SearchController } from '@searchspring/snap-controller';
+import type { Product, SearchResultStore, SearchStoreConfig } from '@searchspring/snap-store-mobx';
+
+const HEADERS = { 'Content-Type': 'application/json', Accept: 'application/json' };
+const MOCK_CART_ID = '123456789';
const ORIGIN = 'http://localhost';
-const ADD_ROUTE = '/remote/v1/cart/add';
-const CART_ROUTE = '/cart.php';
+const CART_ROUTE = '/api/storefront/carts';
+const CART_EXISTS_ROUTE = `/api/storefront/carts/${MOCK_CART_ID}/items`;
+const REDIRECT_ROUTE = '/cart.php';
+const MOCK_ADDED_RESPONSE = { id: MOCK_CART_ID };
const wait = (time = 1) => {
return new Promise((resolve) => {
@@ -36,15 +41,17 @@ const searchConfigDefault = {
},
settings: {},
};
-let results: any;
-let controller: any;
+
+let results: SearchResultStore;
+let controller: SearchController;
let errMock: any;
+let fetchMock: any;
-// @ts-ignore
-const fetchMock = jest.spyOn(global, 'fetch').mockImplementation(() => Promise.resolve({ json: () => Promise.resolve([]), ok: true, status: 200 }));
+const client = new MockClient(globals, {});
+// TODO: need to use variant data from BigCommerce
const controllerServices: any = {
- client: new MockClient(globals, {}),
+ client,
store: new SearchStore(searchConfig, services),
urlManager,
eventManager: new EventManager(),
@@ -63,6 +70,18 @@ describe('addToCart', () => {
results = controller.store.results;
errMock = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ // @ts-ignore
+ fetchMock = jest.spyOn(global, 'fetch').mockImplementation((url) => {
+ let response: any = [];
+ if (url == CART_ROUTE) {
+ response = [{ id: MOCK_CART_ID }];
+ } else if (url == CART_EXISTS_ROUTE) {
+ response = MOCK_ADDED_RESPONSE;
+ }
+
+ return Promise.resolve({ json: () => Promise.resolve(response), ok: true, status: 200 });
+ });
});
beforeEach(() => {
@@ -77,195 +96,368 @@ describe('addToCart', () => {
});
it('requires product(s) to be passed', () => {
- // @ts-ignore
+ // @ts-ignore - adding with no params
addToCart();
expect(fetchMock).not.toHaveBeenCalled();
expect(errMock).toHaveBeenCalledWith('Error: no products to add');
});
- it('adds data passed', () => {
- const item = results[0] as Product;
- addToCart([item]);
-
- const obj = {
- product_id: item.id,
- quantity: item.quantity,
- action: 'add',
- };
- const params = {
- body: JSON.stringify(obj),
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- method: 'POST',
+ it('will log an error when it cannot find a custom id', async () => {
+ const config = {
+ idFieldName: 'mappings.dne.nope',
};
- expect(fetchMock).toHaveBeenCalledWith(ADD_ROUTE, params);
+ const item = results[0] as Product;
- fetchMock.mockClear();
+ await addToCart([item], config);
+
+ expect(errMock).toHaveBeenCalledWith(`Error: couldnt find column in item data. please verify 'idFieldName' in the config.`);
+ expect(fetchMock).not.toHaveBeenCalled();
});
- it('can add multiple quantities', () => {
+ it('will redirect by default', async () => {
const item = results[0] as Product;
- item.quantity = 4;
+ await addToCart([item]);
+ await wait(10);
- addToCart([item]);
+ expect(window.location.href).toEqual(REDIRECT_ROUTE);
- const obj = {
- product_id: item.id,
- quantity: 4,
- action: 'add',
- };
+ fetchMock.mockClear();
+ });
- const params = {
- body: JSON.stringify(obj),
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- method: 'POST',
+ it('will not redirect if config is false', async () => {
+ const item = results[0] as Product;
+ const config = {
+ redirect: false,
};
- expect(fetchMock).toHaveBeenCalledWith(ADD_ROUTE, params);
+ await addToCart([item], config);
+ await wait(10);
- fetchMock.mockClear();
+ expect(window.location.href).toEqual(ORIGIN);
- item.quantity = 1;
+ fetchMock.mockClear();
});
- it('can use alternate id column', () => {
+ it('can use a custom redirect', async () => {
const config = {
- idFieldName: 'mappings.core.url',
+ redirect: 'https://redirect.localhost',
};
const item = results[0] as Product;
- addToCart([item], config);
+ await addToCart([item], config);
+ await wait(10);
- const obj = {
- product_id: item.mappings.core?.url,
- quantity: item.quantity,
- action: 'add',
- };
+ expect(window.location.href).toEqual(config.redirect);
- const params = {
- body: JSON.stringify(obj),
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- method: 'POST',
- };
+ fetchMock.mockClear();
+ });
- expect(fetchMock).toHaveBeenCalledWith(ADD_ROUTE, params);
+ it('will return the API response after adding', async () => {
+ const item = results[0] as Product;
+
+ const response = await addToCart([item]);
+
+ expect(response).toStrictEqual(MOCK_ADDED_RESPONSE);
fetchMock.mockClear();
});
- it('will redirect by default', async () => {
- const item = results[0] as Product;
+ describe('when a cart exists', () => {
+ it('can add a single simple product', async () => {
+ const item = results[0] as Product;
- addToCart([item]);
+ await addToCart([item]);
- const obj = {
- product_id: item.id,
- quantity: item.quantity,
- action: 'add',
- };
- const params = {
- body: JSON.stringify(obj),
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- method: 'POST',
- };
+ const getParams = {
+ headers: HEADERS,
+ method: 'GET',
+ };
- expect(fetchMock).toHaveBeenCalledWith(ADD_ROUTE, params);
+ expect(fetchMock).toHaveBeenCalledWith(CART_ROUTE, getParams);
- await wait(10);
+ const postBody = {
+ lineItems: [
+ {
+ product_id: item.id,
+ quantity: item.quantity,
+ },
+ ],
+ };
- expect(window.location.href).toEqual(CART_ROUTE);
+ const postParams = {
+ headers: HEADERS,
+ method: 'POST',
+ body: JSON.stringify(postBody),
+ };
- fetchMock.mockClear();
- });
+ expect(fetchMock).toHaveBeenLastCalledWith(CART_EXISTS_ROUTE, postParams);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
- it('will not redirect if config is false', async () => {
- const item = results[0] as Product;
- const config = {
- redirect: false,
- };
+ fetchMock.mockClear();
+ });
- addToCart([item], config);
+ it('can add a single product with options', async () => {
+ client.mockData.updateConfig({ siteId: 'tfdz6e', search: 'variants' });
+ const optionSearchConfig: SearchStoreConfig = {
+ ...searchConfig,
+ settings: {
+ redirects: {
+ singleResult: false,
+ },
+ variants: {
+ field: 'ss_variants',
+ },
+ },
+ };
+ const optionController = new SearchController(optionSearchConfig, controllerServices);
- const obj = {
- product_id: item.id,
- quantity: item.quantity,
- action: 'add',
- };
- const params = {
- body: JSON.stringify(obj),
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- method: 'POST',
- };
+ await optionController.search();
- expect(fetchMock).toHaveBeenCalledWith(ADD_ROUTE, params);
+ const results = optionController.store.results;
- await wait(10);
+ const item = results[0] as Product;
- expect(window.location.href).toEqual(ORIGIN);
+ await addToCart([item]);
- fetchMock.mockClear();
- });
+ const getParams = {
+ headers: HEADERS,
+ method: 'GET',
+ };
- it('can use a custom redirect', async () => {
- const config = {
- redirect: 'https://redirect.localhost',
- };
+ expect(fetchMock).toHaveBeenCalledWith(CART_ROUTE, getParams);
+
+ const postBody = {
+ lineItems: [
+ {
+ product_id: item.display.mappings.core?.uid,
+ quantity: item.quantity,
+ optionSelections: [
+ {
+ optionId: 570,
+ optionValue: 2900,
+ },
+ ],
+ },
+ ],
+ };
- const item = results[0] as Product;
+ const postParams = {
+ headers: HEADERS,
+ method: 'POST',
+ body: JSON.stringify(postBody),
+ };
- addToCart([item], config);
+ expect(fetchMock).toHaveBeenLastCalledWith(CART_EXISTS_ROUTE, postParams);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
- const obj = {
- product_id: item.id,
- quantity: item.quantity,
- action: 'add',
- };
- const params = {
- body: JSON.stringify(obj),
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- method: 'POST',
- };
+ fetchMock.mockClear();
+ });
- expect(fetchMock).toHaveBeenCalledWith(ADD_ROUTE, params);
+ it('can add multiple items', async () => {
+ const items = results.slice(0, 3) as Product[];
+ items.forEach((item) => item.quantity++);
- await wait(10);
+ await addToCart(items);
- expect(window.location.href).toEqual(config.redirect);
+ const getParams = {
+ headers: HEADERS,
+ method: 'GET',
+ };
- fetchMock.mockClear();
+ expect(fetchMock).toHaveBeenCalledWith(CART_ROUTE, getParams);
+
+ const postBody = {
+ lineItems: items.map((item) => ({
+ product_id: item.id,
+ quantity: item.quantity,
+ })),
+ };
+
+ const postParams = {
+ headers: HEADERS,
+ method: 'POST',
+ body: JSON.stringify(postBody),
+ };
+
+ expect(fetchMock).toHaveBeenLastCalledWith(CART_EXISTS_ROUTE, postParams);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ fetchMock.mockClear();
+ });
+
+ it('can use alternate id column', async () => {
+ const config = {
+ idFieldName: 'mappings.core.sku',
+ };
+
+ const item = results[0] as Product;
+
+ await addToCart([item], config);
+
+ const getParams = {
+ headers: HEADERS,
+ method: 'GET',
+ };
+
+ expect(fetchMock).toHaveBeenCalledWith(CART_ROUTE, getParams);
+
+ const postBody = {
+ lineItems: [
+ {
+ product_id: item.mappings.core?.sku,
+ quantity: item.quantity,
+ },
+ ],
+ };
+
+ const postParams = {
+ headers: HEADERS,
+ method: 'POST',
+ body: JSON.stringify(postBody),
+ };
+
+ expect(fetchMock).toHaveBeenLastCalledWith(CART_EXISTS_ROUTE, postParams);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ fetchMock.mockClear();
+ });
});
- it('can add multiple items', async () => {
- const items = results.slice(0, 3) as Product[];
- addToCart(items);
+ describe('when NO cart exists', () => {
+ beforeAll(() => {
+ // @ts-ignore
+ fetchMock = jest.spyOn(global, 'fetch').mockImplementation((url) => {
+ let response: any = [];
+ if (url == CART_EXISTS_ROUTE) {
+ response = MOCK_ADDED_RESPONSE;
+ }
+
+ return Promise.resolve({ json: () => Promise.resolve(response), ok: true, status: 200 });
+ });
+ });
+
+ it('can add a single simple product', async () => {
+ const item = results[0] as Product;
- for (let i = 0; i < items.length; i++) {
- const obj = {
- product_id: items[i].id,
- quantity: items[i].quantity,
- action: 'add',
+ await addToCart([item]);
+
+ const getParams = {
+ headers: HEADERS,
+ method: 'GET',
};
- const params = {
- body: JSON.stringify(obj),
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+
+ expect(fetchMock).toHaveBeenCalledWith(CART_ROUTE, getParams);
+
+ const postBody = {
+ lineItems: [
+ {
+ product_id: item.id,
+ quantity: item.quantity,
+ },
+ ],
+ };
+
+ const postParams = {
+ headers: HEADERS,
method: 'POST',
+ body: JSON.stringify(postBody),
};
- await wait(10);
+ expect(fetchMock).toHaveBeenLastCalledWith(CART_ROUTE, postParams);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
- expect(fetchMock).toHaveBeenCalledWith(ADD_ROUTE, params);
- }
+ fetchMock.mockClear();
+ });
- fetchMock.mockClear();
+ it('can add a single product with options', async () => {
+ client.mockData.updateConfig({ siteId: 'tfdz6e', search: 'variants' });
+ const optionSearchConfig: SearchStoreConfig = {
+ ...searchConfig,
+ settings: {
+ redirects: {
+ singleResult: false,
+ },
+ variants: {
+ field: 'ss_variants',
+ },
+ },
+ };
+ const optionController = new SearchController(optionSearchConfig, controllerServices);
+
+ await optionController.search();
+
+ const results = optionController.store.results;
+
+ const item = results[0] as Product;
+
+ await addToCart([item]);
+
+ const getParams = {
+ headers: HEADERS,
+ method: 'GET',
+ };
+
+ expect(fetchMock).toHaveBeenCalledWith(CART_ROUTE, getParams);
+
+ const postBody = {
+ lineItems: [
+ {
+ product_id: item.display.mappings.core?.uid,
+ quantity: item.quantity,
+ optionSelections: [
+ {
+ optionId: 570,
+ optionValue: 2900,
+ },
+ ],
+ },
+ ],
+ };
+
+ const postParams = {
+ headers: HEADERS,
+ method: 'POST',
+ body: JSON.stringify(postBody),
+ };
+
+ expect(fetchMock).toHaveBeenLastCalledWith(CART_ROUTE, postParams);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ fetchMock.mockClear();
+ });
+
+ it('can add multiple items', async () => {
+ const items = results.slice(0, 3) as Product[];
+ await addToCart(items);
+
+ const getParams = {
+ headers: HEADERS,
+ method: 'GET',
+ };
+
+ expect(fetchMock).toHaveBeenCalledWith(CART_ROUTE, getParams);
+
+ const postBody = {
+ lineItems: items.map((item) => ({
+ product_id: item.id,
+ quantity: item.quantity,
+ })),
+ };
+
+ const postParams = {
+ headers: HEADERS,
+ method: 'POST',
+ body: JSON.stringify(postBody),
+ };
+
+ expect(fetchMock).toHaveBeenLastCalledWith(CART_ROUTE, postParams);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ fetchMock.mockClear();
+ });
});
});
diff --git a/packages/snap-platforms/bigcommerce/src/addToCart.ts b/packages/snap-platforms/bigcommerce/src/addToCart.ts
index 47c96767c..59ff2d177 100644
--- a/packages/snap-platforms/bigcommerce/src/addToCart.ts
+++ b/packages/snap-platforms/bigcommerce/src/addToCart.ts
@@ -8,10 +8,7 @@ type BigCommerceAddToCartConfig = {
type LineItem = {
product_id: string;
quantity: number;
-};
-
-type FormData = {
- line_items: LineItem[];
+ optionSelections?: { optionId?: string; optionValue?: string }[];
};
export const addToCart = async (items: Product[], config?: BigCommerceAddToCartConfig) => {
@@ -20,9 +17,7 @@ export const addToCart = async (items: Product[], config?: BigCommerceAddToCartC
return;
}
- const formData: FormData = {
- line_items: [],
- };
+ const lineItems: LineItem[] = [];
items.map((item) => {
let id = item?.display?.mappings?.core?.uid;
@@ -31,78 +26,108 @@ export const addToCart = async (items: Product[], config?: BigCommerceAddToCartC
if (config?.idFieldName) {
let level: any = item;
config.idFieldName.split('.').map((field) => {
- if (level[field]) {
+ if (level && level[field]) {
level = level[field];
} else {
- console.error('Error: couldnt find column in item data. please check your idFieldName is correct in the config.');
+ console.error(`Error: couldnt find column in item data. please verify 'idFieldName' in the config.`);
+ level = undefined;
+ id = undefined;
return;
}
});
+
if (level && level !== item) {
id = level;
}
}
if (id && item.quantity) {
- const obj = {
+ const productDetails: LineItem = {
product_id: id,
quantity: item.quantity,
};
- formData.line_items.push(obj);
+ const options = item.variants?.active?.options;
+ if (options) {
+ productDetails.optionSelections = [];
+ Object.keys(options).forEach((option) => {
+ const optionId = options[option].optionId;
+ const optionValue = options[option].optionValue;
+
+ if (optionId && optionValue) {
+ productDetails.optionSelections?.push({ optionId, optionValue });
+ }
+ });
+ }
+
+ lineItems.push(productDetails);
}
});
- // first check how many products we are adding
- if (formData.line_items.length) {
- for (let i = 0; i < formData.line_items.length; i++) {
- await addSingleProductv1(formData.line_items[i]);
+ if (lineItems.length) {
+ const addToCartResponse = await addLineItemsToCart(lineItems);
+
+ // do redirect (or not)
+ if (config?.redirect !== false) {
+ setTimeout(() => (window.location.href = typeof config?.redirect == 'string' ? config?.redirect : '/cart.php'));
}
- }
- // do redirect (or not)
- if (config?.redirect !== false) {
- setTimeout(() => (window.location.href = typeof config?.redirect == 'string' ? config?.redirect : '/cart.php'));
+ return addToCartResponse;
}
};
-const addSingleProductv1 = async (item: LineItem) => {
- if (!item) {
- console.error('Error: no product to add');
- return;
- }
+async function addLineItemsToCart(lineItems: LineItem[]): Promise {
+ try {
+ const cartId = await getExistingCartId();
- const endpoint = {
- route: `/remote/v1/cart/add`,
- method: 'POST',
- accept: 'application/json',
- content: 'application/json',
- success: 200,
- };
+ // if existing cartId use it, otherwise create new cart with items
+ let addToCartUrl = '/api/storefront/carts';
+ if (cartId) {
+ addToCartUrl = `/api/storefront/carts/${cartId}/items`;
+ }
- try {
- const payload = JSON.stringify({
- ...item,
- action: 'add',
+ const body = JSON.stringify({ lineItems });
+
+ const response = await fetch(addToCartUrl, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body,
});
- const init: RequestInit = {
- method: endpoint.method,
- credentials: 'same-origin',
+ if (response.status !== 200) {
+ throw new Error(`API rejected addToCart: ${response.status}`);
+ }
+
+ const responseData = await response.json();
+
+ if (responseData?.id) {
+ // cart Id should exist now.
+ return responseData;
+ }
+ } catch (err) {
+ console.error(`Error: could not add to cart.`, err);
+ }
+}
+
+async function getExistingCartId(): Promise {
+ try {
+ const response = await fetch('/api/storefront/carts', {
+ method: 'GET',
headers: {
- // note: no authorization
- Accept: endpoint.accept,
- 'Content-Type': endpoint.content,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
},
- body: payload,
- };
+ });
- const response = await fetch(endpoint.route, init);
+ const responseData = await response.json();
- if (response.status !== endpoint.success) {
- throw new Error(`Error: addToCart responded with ${response.status}, ${response}`);
+ if (Array.isArray(responseData) && responseData.length) {
+ return responseData[0].id;
}
} catch (err) {
- console.error(err);
+ // error...
}
-};
+}
diff --git a/packages/snap-shared/src/MockData/meta/tfdz6e/meta.json b/packages/snap-shared/src/MockData/meta/tfdz6e/meta.json
new file mode 100644
index 000000000..7069718fb
--- /dev/null
+++ b/packages/snap-shared/src/MockData/meta/tfdz6e/meta.json
@@ -0,0 +1,148 @@
+{
+ "facets": {
+ "categories_hierarchy": {
+ "multiple": "single",
+ "display": "hierarchy",
+ "label": "Category",
+ "collapsed": true,
+ "hierarchyDelimiter": ">"
+ },
+ "custom_color": {
+ "multiple": "or",
+ "display": "palette",
+ "label": "Color",
+ "collapsed": true
+ },
+ "custom_depth": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Depth",
+ "collapsed": true
+ },
+ "custom_diameter": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Diameter",
+ "collapsed": true
+ },
+ "custom_head_diameter": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Head Diameter",
+ "collapsed": true
+ },
+ "custom_height": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Height",
+ "collapsed": true
+ },
+ "custom_hole_size": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Hole Size",
+ "collapsed": true
+ },
+ "custom_material": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Type of Wood",
+ "collapsed": true
+ },
+ "custom_shape": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Shape",
+ "collapsed": true
+ },
+ "custom_tenon_diameter": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Tenon Diameter",
+ "collapsed": true
+ },
+ "custom_tenon_length": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Tenon Length",
+ "collapsed": true
+ },
+ "custom_type_of_metal": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Type of Metal",
+ "collapsed": true
+ },
+ "result_depth": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Thickness",
+ "collapsed": true
+ },
+ "result_height": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Length",
+ "collapsed": true
+ },
+ "result_width": {
+ "multiple": "or",
+ "display": "list",
+ "label": "Width",
+ "collapsed": true
+ }
+ },
+ "sortOptions": [
+ {
+ "type": "relevance",
+ "field": "relevance",
+ "direction": "desc",
+ "label": "Best Match"
+ },
+ {
+ "type": "field",
+ "field": "total_sold",
+ "direction": "desc",
+ "label": "Most Popular"
+ },
+ {
+ "type": "field",
+ "field": "ss_days_since_created",
+ "direction": "asc",
+ "label": "Newest"
+ },
+ {
+ "type": "field",
+ "field": "calculated_price",
+ "direction": "asc",
+ "label": "Price: Low to High"
+ },
+ {
+ "type": "field",
+ "field": "calculated_price",
+ "direction": "desc",
+ "label": "Price: High to Low"
+ },
+ {
+ "type": "field",
+ "field": "ss_diameter_inches",
+ "direction": "asc",
+ "label": "Diameter: Low to High"
+ },
+ {
+ "type": "field",
+ "field": "ss_diameter_inches",
+ "direction": "desc",
+ "label": "Diameter: High to Low"
+ },
+ {
+ "type": "field",
+ "field": "rating_average",
+ "direction": "desc",
+ "label": "Highest Rated"
+ }
+ ],
+ "pagination": {
+ "defaultPageSize": 72
+ }
+}
\ No newline at end of file
diff --git a/packages/snap-shared/src/MockData/search/tfdz6e/variants.json b/packages/snap-shared/src/MockData/search/tfdz6e/variants.json
new file mode 100644
index 000000000..fbdef38f3
--- /dev/null
+++ b/packages/snap-shared/src/MockData/search/tfdz6e/variants.json
@@ -0,0 +1,356 @@
+{
+ "pagination": {
+ "totalResults": 1,
+ "page": 1,
+ "pageSize": 30,
+ "totalPages": 1
+ },
+ "results": [
+ {
+ "id": "4007",
+ "mappings": {
+ "core": {
+ "uid": "4007",
+ "sku": "LS-EEBP",
+ "name": "Easter Egg with Boho Etched Pattern",
+ "url": "/easter-egg-with-boho-etched-pattern/",
+ "price": 0,
+ "msrp": 0,
+ "imageUrl": "https://cdn11.bigcommerce.com/s-6d1tnboxyx/images/stencil/500x659/products/4007/27880/woodpeckers-crafts-easter-egg-with-boho-etched-pattern__44621.1.jpg",
+ "thumbnailImageUrl": "https://cdn11.bigcommerce.com/s-6d1tnboxyx/images/stencil/500x659/products/4007/27880/woodpeckers-crafts-easter-egg-with-boho-etched-pattern__44621.1.jpg",
+ "ratingCount": "0",
+ "brand": "Woodpeckers Crafts",
+ "popularity": "279"
+ }
+ },
+ "attributes": {
+ "availability": "available",
+ "categories": [
+ "Plywood and Wood Cutouts",
+ "Wooden Seasonal Cutouts",
+ "Wood Spring & Easter Cutouts",
+ "Shop By Season",
+ "Easter and Spring",
+ "Laser Cutouts"
+ ],
+ "categories_hierarchy": [
+ "Plywood and Wood Cutouts",
+ "Plywood and Wood Cutouts>Wooden Seasonal Cutouts",
+ "Plywood and Wood Cutouts>Wooden Seasonal Cutouts>Wood Spring & Easter Cutouts",
+ "Shop By Season",
+ "Shop By Season>Easter and Spring",
+ "Plywood and Wood Cutouts>Laser Cutouts"
+ ],
+ "cdn_images": "28082,woodpeckers-crafts-easter-egg-with-boho-etched-pattern__24263,jpg|27929,woodpeckers-crafts-easter-egg-with-boho-etched-pattern__44790,jpg|28010,woodpeckers-crafts-easter-egg-with-boho-etched-pattern__22446,jpg|27877,woodpeckers-crafts-easter-egg-with-boho-etched-pattern__70969,jpg",
+ "child_sku_options": "[{\"option_value_id\":2900,\"value\":\"5 Inch\",\"option_id\":570,\"option\":\"Size\",\"product_option_id\":570,\"id\":8732,\"product_id\":4007,\"child_sku\":\"LS-EEBP-5\",\"price\":0.68,\"calculated_price\":0.68,\"retail_price\":null,\"width\":3.7175,\"height\":5,\"depth\":0.125,\"purchasing_disabled\":false,\"image_url\":\"\",\"cost_price\":0,\"upc\":\"\",\"mpn\":\"\",\"inventory_level\":99999},{\"option_value_id\":2901,\"value\":\"6 Inch\",\"option_id\":570,\"option\":\"Size\",\"product_option_id\":570,\"id\":8733,\"product_id\":4007,\"child_sku\":\"LS-EEBP-6\",\"price\":1.05,\"calculated_price\":1.05,\"retail_price\":null,\"width\":4.461,\"height\":6,\"depth\":0.125,\"purchasing_disabled\":false,\"image_url\":\"\",\"cost_price\":0,\"upc\":\"\",\"mpn\":\"\",\"inventory_level\":99999},{\"option_value_id\":2902,\"value\":\"7 Inch\",\"option_id\":570,\"option\":\"Size\",\"product_option_id\":570,\"id\":8734,\"product_id\":4007,\"child_sku\":\"LS-EEBP-7\",\"price\":1.28,\"calculated_price\":1.28,\"retail_price\":null,\"width\":5.2045,\"height\":7,\"depth\":0.125,\"purchasing_disabled\":false,\"image_url\":\"\",\"cost_price\":0,\"upc\":\"\",\"mpn\":\"\",\"inventory_level\":99999},{\"option_value_id\":2903,\"value\":\"8 Inch\",\"option_id\":570,\"option\":\"Size\",\"product_option_id\":570,\"id\":8735,\"product_id\":4007,\"child_sku\":\"LS-EEBP-8\",\"price\":1.79,\"calculated_price\":1.79,\"retail_price\":null,\"width\":5.948,\"height\":8,\"depth\":0.125,\"purchasing_disabled\":false,\"image_url\":\"\",\"cost_price\":0,\"upc\":\"\",\"mpn\":\"\",\"inventory_level\":99999},{\"option_value_id\":2904,\"value\":\"9 Inch\",\"option_id\":570,\"option\":\"Size\",\"product_option_id\":570,\"id\":8736,\"product_id\":4007,\"child_sku\":\"LS-EEBP-9\",\"price\":2.35,\"calculated_price\":2.35,\"retail_price\":null,\"width\":6.6915,\"height\":9,\"depth\":0.125,\"purchasing_disabled\":false,\"image_url\":\"\",\"cost_price\":0,\"upc\":\"\",\"mpn\":\"\",\"inventory_level\":99999},{\"option_value_id\":2905,\"value\":\"10 Inch\",\"option_id\":570,\"option\":\"Size\",\"product_option_id\":570,\"id\":8737,\"product_id\":4007,\"child_sku\":\"LS-EEBP-10\",\"price\":3.23,\"calculated_price\":3.23,\"retail_price\":null,\"width\":7.435,\"height\":10,\"depth\":0.125,\"purchasing_disabled\":false,\"image_url\":\"\",\"cost_price\":0,\"upc\":\"\",\"mpn\":\"\",\"inventory_level\":99999},{\"option_value_id\":2906,\"value\":\"12 Inch\",\"option_id\":570,\"option\":\"Size\",\"product_option_id\":570,\"id\":8738,\"product_id\":4007,\"child_sku\":\"LS-EEBP-12\",\"price\":4.71,\"calculated_price\":4.71,\"retail_price\":null,\"width\":8.922,\"height\":12,\"depth\":0.125,\"purchasing_disabled\":false,\"image_url\":\"\",\"cost_price\":0,\"upc\":\"\",\"mpn\":\"\",\"inventory_level\":99999},{\"option_value_id\":2907,\"value\":\"14 Inch\",\"option_id\":570,\"option\":\"Size\",\"product_option_id\":570,\"id\":8739,\"product_id\":4007,\"child_sku\":\"LS-EEBP-14\",\"price\":5.65,\"calculated_price\":5.65,\"retail_price\":null,\"width\":10.409,\"height\":14,\"depth\":0.125,\"purchasing_disabled\":false,\"image_url\":\"\",\"cost_price\":0,\"upc\":\"\",\"mpn\":\"\",\"inventory_level\":99999},{\"option_value_id\":2908,\"value\":\"16 Inch\",\"option_id\":570,\"option\":\"Size\",\"product_option_id\":570,\"id\":8740,\"product_id\":4007,\"child_sku\":\"LS-EEBP-16\",\"price\":9.41,\"calculated_price\":9.41,\"retail_price\":null,\"width\":11.896,\"height\":16,\"depth\":0.125,\"purchasing_disabled\":false,\"image_url\":\"\",\"cost_price\":0,\"upc\":\"\",\"mpn\":\"\",\"inventory_level\":99999},{\"option_value_id\":2909,\"value\":\"18 Inch\",\"option_id\":570,\"option\":\"Size\",\"product_option_id\":570,\"id\":8741,\"product_id\":4007,\"child_sku\":\"LS-EEBP-18\",\"price\":9.41,\"calculated_price\":9.41,\"retail_price\":null,\"width\":13.383,\"height\":18,\"depth\":0.125,\"purchasing_disabled\":false,\"image_url\":\"\",\"cost_price\":0,\"upc\":\"\",\"mpn\":\"\",\"inventory_level\":99999}]",
+ "child_skus": [
+ "LS-EEBP-5",
+ "LS-EEBP-6",
+ "LS-EEBP-7",
+ "LS-EEBP-8",
+ "LS-EEBP-9",
+ "LS-EEBP-10",
+ "LS-EEBP-12",
+ "LS-EEBP-14",
+ "LS-EEBP-16",
+ "LS-EEBP-18"
+ ],
+ "depth": "0",
+ "height": "0",
+ "id": "8f3b460ca891ef8375d35152b79d19fd",
+ "images": "k/383/woodpeckers-crafts-easter-egg-with-boho-etched-pattern__24263.jpg|r/555/woodpeckers-crafts-easter-egg-with-boho-etched-pattern__44790.jpg|j/898/woodpeckers-crafts-easter-egg-with-boho-etched-pattern__22446.jpg|p/674/woodpeckers-crafts-easter-egg-with-boho-etched-pattern__70969.jpg",
+ "intellisuggestData": "eJwqSUupMktlYEhNLC5JLVJITU9XSMrPyGfwCdZ1dXUKYDBkMGQwYDBkSC_KTAEEAAD__zAJDEQ",
+ "intellisuggestSignature": "9b2f31f2e39929f18576d83d9d1fb072ca4928a4761e74d09ff91d0388dcbdfb",
+ "inventory_level": "999990",
+ "inventory_tracking": "sku",
+ "is_featured": "false",
+ "is_free_shipping": "false",
+ "is_visible": "true",
+ "map_price": "0",
+ "option_set_id": "529",
+ "product_type_unigram": "egg",
+ "rating_count": "0",
+ "result_depth": [
+ "1/8\""
+ ],
+ "result_height": [
+ "5\"",
+ "6\"",
+ "7\"",
+ "8\"",
+ "9\"",
+ "10\"",
+ "12\"",
+ "14\"",
+ "16\"",
+ "18\""
+ ],
+ "result_width": [
+ "3-11/16\"",
+ "4-7/16\"",
+ "5-3/16\"",
+ "5-15/16\"",
+ "6-11/16\"",
+ "7-7/16\"",
+ "8-15/16\"",
+ "10-7/16\"",
+ "11-7/8\"",
+ "13-3/8\""
+ ],
+ "reviews_count": "0",
+ "reviews_rating_sum": "0",
+ "ss_days_since_created": "129",
+ "ss_filter_depth": [
+ "0.125"
+ ],
+ "ss_filter_height": [
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ "10",
+ "12",
+ "14",
+ "16",
+ "18"
+ ],
+ "ss_filter_width": [
+ "3.7175",
+ "4.461",
+ "5.2045",
+ "5.948",
+ "6.6915",
+ "7.435",
+ "8.922",
+ "10.409",
+ "11.896",
+ "13.383"
+ ],
+ "ss_hover_image": "https://cdn11.bigcommerce.com/s-6d1tnboxyx/images/stencil/500x659/products/4007/28082/woodpeckers-crafts-easter-egg-with-boho-etched-pattern__24263.1.jpg",
+ "ss_image": "https://cdn11.bigcommerce.com/s-6d1tnboxyx/images/stencil/500x659/products/4007/27880/woodpeckers-crafts-easter-egg-with-boho-etched-pattern__44621.1.jpg",
+ "ss_in_stock": "1",
+ "ss_price_range": [
+ "0.68",
+ "9.41"
+ ],
+ "ss_variant_depth": [
+ "0.125"
+ ],
+ "ss_variant_height": [
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ "10",
+ "12",
+ "14",
+ "16",
+ "18"
+ ],
+ "ss_variant_width": [
+ "3.7175",
+ "4.461",
+ "5.2045",
+ "5.948",
+ "6.6915",
+ "7.435",
+ "8.922",
+ "10.409",
+ "11.896",
+ "13.383"
+ ],
+ "ss_variants": "[{\"mappings\":{\"core\":{\"uid\":4007,\"price\":0.68,\"sku\":\"LS-EEBP-5\"}},\"attributes\":{\"quantity\":99999,\"available\":true},\"options\":{\"Size\":{\"value\":\"5 Inch\",\"optionValue\":2900,\"optionId\":570}}},{\"mappings\":{\"core\":{\"uid\":4007,\"price\":1.05,\"sku\":\"LS-EEBP-6\"}},\"attributes\":{\"quantity\":99999,\"available\":true},\"options\":{\"Size\":{\"value\":\"6 Inch\",\"optionValue\":2901,\"optionId\":570}}},{\"mappings\":{\"core\":{\"uid\":4007,\"price\":1.28,\"sku\":\"LS-EEBP-7\"}},\"attributes\":{\"quantity\":99999,\"available\":true},\"options\":{\"Size\":{\"value\":\"7 Inch\",\"optionValue\":2902,\"optionId\":570}}},{\"mappings\":{\"core\":{\"uid\":4007,\"price\":1.79,\"sku\":\"LS-EEBP-8\"}},\"attributes\":{\"quantity\":99999,\"available\":true},\"options\":{\"Size\":{\"value\":\"8 Inch\",\"optionValue\":2903,\"optionId\":570}}},{\"mappings\":{\"core\":{\"uid\":4007,\"price\":2.35,\"sku\":\"LS-EEBP-9\"}},\"attributes\":{\"quantity\":99999,\"available\":true},\"options\":{\"Size\":{\"value\":\"9 Inch\",\"optionValue\":2904,\"optionId\":570}}},{\"mappings\":{\"core\":{\"uid\":4007,\"price\":3.23,\"sku\":\"LS-EEBP-10\"}},\"attributes\":{\"quantity\":99999,\"available\":true},\"options\":{\"Size\":{\"value\":\"10 Inch\",\"optionValue\":2905,\"optionId\":570}}},{\"mappings\":{\"core\":{\"uid\":4007,\"price\":4.71,\"sku\":\"LS-EEBP-12\"}},\"attributes\":{\"quantity\":99999,\"available\":true},\"options\":{\"Size\":{\"value\":\"12 Inch\",\"optionValue\":2906,\"optionId\":570}}},{\"mappings\":{\"core\":{\"uid\":4007,\"price\":5.65,\"sku\":\"LS-EEBP-14\"}},\"attributes\":{\"quantity\":99999,\"available\":true},\"options\":{\"Size\":{\"value\":\"14 Inch\",\"optionValue\":2907,\"optionId\":570}}},{\"mappings\":{\"core\":{\"uid\":4007,\"price\":9.41,\"sku\":\"LS-EEBP-16\"}},\"attributes\":{\"quantity\":99999,\"available\":true},\"options\":{\"Size\":{\"value\":\"16 Inch\",\"optionValue\":2908,\"optionId\":570}}},{\"mappings\":{\"core\":{\"uid\":4007,\"price\":9.41,\"sku\":\"LS-EEBP-18\"}},\"attributes\":{\"quantity\":99999,\"available\":true},\"options\":{\"Size\":{\"value\":\"18 Inch\",\"optionValue\":2909,\"optionId\":570}}}]",
+ "ss_visibility": "1",
+ "total_sold": "279",
+ "width": "0"
+ },
+ "children": []
+ }
+ ],
+ "filters": [],
+ "facets": [
+ {
+ "field": "result_height",
+ "type": "value",
+ "filtered": false,
+ "values": [
+ {
+ "filtered": false,
+ "value": "5\"",
+ "label": "5\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "6\"",
+ "label": "6\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "7\"",
+ "label": "7\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "8\"",
+ "label": "8\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "9\"",
+ "label": "9\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "10\"",
+ "label": "10\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "12\"",
+ "label": "12\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "14\"",
+ "label": "14\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "16\"",
+ "label": "16\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "18\"",
+ "label": "18\"",
+ "count": 1
+ }
+ ]
+ },
+ {
+ "field": "result_width",
+ "type": "value",
+ "filtered": false,
+ "values": [
+ {
+ "filtered": false,
+ "value": "3-11/16\"",
+ "label": "3-11/16\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "4-7/16\"",
+ "label": "4-7/16\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "5-3/16\"",
+ "label": "5-3/16\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "5-15/16\"",
+ "label": "5-15/16\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "6-11/16\"",
+ "label": "6-11/16\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "7-7/16\"",
+ "label": "7-7/16\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "8-15/16\"",
+ "label": "8-15/16\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "10-7/16\"",
+ "label": "10-7/16\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "11-7/8\"",
+ "label": "11-7/8\"",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "13-3/8\"",
+ "label": "13-3/8\"",
+ "count": 1
+ }
+ ]
+ },
+ {
+ "field": "result_depth",
+ "type": "value",
+ "filtered": false,
+ "values": [
+ {
+ "filtered": false,
+ "value": "1/8\"",
+ "label": "1/8\"",
+ "count": 1
+ }
+ ]
+ },
+ {
+ "field": "categories_hierarchy",
+ "type": "value",
+ "filtered": false,
+ "values": [
+ {
+ "filtered": false,
+ "value": "Plywood and Wood Cutouts",
+ "label": "Plywood and Wood Cutouts",
+ "count": 1
+ },
+ {
+ "filtered": false,
+ "value": "Shop By Season",
+ "label": "Shop By Season",
+ "count": 1
+ }
+ ]
+ }
+ ],
+ "sorting": [],
+ "merchandising": {
+ "redirect": "",
+ "content": {},
+ "campaigns": [
+ {
+ "id": "163014",
+ "title": "Global Boost Rules (Cutouts and 1-3/4\" Cube and Coffins and 12x12x1/4 plywood)",
+ "type": "global"
+ }
+ ]
+ },
+ "search": {
+ "query": "easter egg boho"
+ }
+}
\ No newline at end of file
diff --git a/packages/snap-store-mobx/src/Meta/README.md b/packages/snap-store-mobx/src/Meta/README.md
new file mode 100644
index 000000000..25da418a9
--- /dev/null
+++ b/packages/snap-store-mobx/src/Meta/README.md
@@ -0,0 +1,53 @@
+# MetaStore
+
+The `MetaStore` contains the response from the Searchspring meta API which includes information about site configuration and feature settings. A `MetaStore` can be found on each root store's `meta` property. These include:
+
+- [SearchStore](https://github.com/searchspring/snap/tree/main/packages/snap-store-mobx/src/Search)
+- [AutocompleteStore](https://github.com/searchspring/snap/tree/main/packages/snap-store-mobx/src/Autocomplete)
+- [RecommendationStore](https://github.com/searchspring/snap/tree/main/packages/snap-store-mobx/src/Recommendation)
+- [FinderStore](https://github.com/searchspring/snap/tree/main/packages/snap-store-mobx/src/Finder)
+
+
+## `data` property
+The `data` property contains the raw meta API response
+
+
+## `badges` property
+The badges property contains a reference to `MetaBadges` class
+
+# MetaBadges
+
+The `MetaBadges` class constructs data related to overlay badge layouts used in the `OverlayBadge` component
+
+## `groups` property
+
+The `groups` property is a mapping of overlay groups used by the `OverlayBadge` component to create CSS `grid-template-areas` and `grid-template-columns` values. It ensures that if a custom location mapping contains uneven length of locations in each section, the named grid areas can find a common denomination of sliced areas in the grid template
+
+If you are not utilizing the `OverlayBadge` component to display [Badges](https://github.com/searchspring/snap/blob/main/docs/PREACT_BADGES.md) and creating a custom container that also utilizes css grid for overlay locations, this property can be used as a helper as it will handle changes to adding additional badge locations
+
+The default locations contain a single 'overlay' group with 1 location in each section
+
+```json
+{
+ "overlay": {
+ "sections": [
+ "left",
+ "right"
+ ],
+ "grid": [
+ [
+ "left",
+ "right"
+ ]
+ ]
+ }
+}
+```
+
+To create the following overlay grid:
+
+```css
+display: grid;
+grid-template-columns: repeat(2, 1fr);
+grid-template-areas: "left right";
+```
\ No newline at end of file
diff --git a/packages/snap-store-mobx/src/Search/Stores/SearchResultStore.ts b/packages/snap-store-mobx/src/Search/Stores/SearchResultStore.ts
index 66e6cb42d..e0593d229 100644
--- a/packages/snap-store-mobx/src/Search/Stores/SearchResultStore.ts
+++ b/packages/snap-store-mobx/src/Search/Stores/SearchResultStore.ts
@@ -130,6 +130,7 @@ export type VariantDataOptions = Record<
backgroundImageUrl?: string;
attributeId?: string;
optionId?: string;
+ optionValue?: string;
}
>;
@@ -322,6 +323,16 @@ export class Variants {
// create variants objects
this.data = variantData
.filter((variant) => variant.attributes.available !== false)
+ .map((variant) => {
+ // normalize price fields ensuring they are numbers
+ if (variant.mappings.core?.price) {
+ variant.mappings.core.price = Number(variant.mappings.core?.price);
+ }
+ if (variant.mappings.core?.msrp) {
+ variant.mappings.core.msrp = Number(variant.mappings.core?.msrp);
+ }
+ return variant;
+ })
.map((variant) => {
Object.keys(variant.options).forEach((variantOption) => {
if (!options.includes(variantOption)) {