When the Shopify port of littlegiantladder.com was launched, it was lacking the ability to show dynamic cross-sells based on the products in the cart. My team knew from experience that this was a big loss in conversion from many previous tests and available data online. I knew I could edit each product's metafield data through the Shopify Metafield API but there were some unique challenges to this task that weren't fullfilled by the existing apps on the Shopify App Store.
Shopify's metafield structure is intended for adding custom attributes to a product using a single unique value per key, and I needed one to many values per key. I also wanted an interface that was clean and designed around the intended use for the other members of my company to easily use and maintain over a long period.
I was coming up on a holiday break, and I decided to use the long weekend to write my first real-world Redux application. I'd been looking for a proper use-case for Redux and this seemed like it might fit. In the Shopify Apps documentation there was a reference to 'shopify-node-app', a starter template using Node, React, Redux, and Shopify's React styled components library: Polaris. Perfect, exactly what I was looking for as a starting point.
After I had gone through several Redux guides as a refresher, and combed through the template's code for any practices I didn't know, I decided to strip out all of the starter Redux code and write my own for a better understanding of what was going on.
To me it makes the most sense to start with the actions controller then move to its associated reducer. Here is the final product actions file. If you are unfamiliar with using dispatch within a dispatch, it is the syntax for using thunk middleware. A thunk wraps an expression to delay it, which is particularly useful during an API request in which you need to update your state when it completes. Here is a good explanation from the Github source.
It took me a good while to get my head around the concept originally. I had a difficult time finding examples of Redux API fetches (crazy, I know) so when I learned this was a common practice for it I decided to give it a whirl.
export function fetchProducts() {
const fetchOptions = {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'include',
}
return (dispatch) => {
dispatch({ type: 'FETCH_PRODUCTS_START' });
fetch('/api/products.json', fetchOptions)
.then((res) => res.json())
.then((json) => dispatch({ type: 'FETCH_PRODUCTS_COMPLETE', payload: json }))
.catch((error) => dispatch({ type: 'FETCH_PRODUCTS_ERROR', payload: error }))
;
}
}
And here is the productsReducer. Everything is pretty standard here.
const initState = {
products: [],
fetching: false,
error: null
}
export default function(state = initState, action) {
switch(action.type) {
case 'FETCH_PRODUCTS_START':
return {
...state,
fetching: true,
error: null
}
case 'FETCH_PRODUCTS_COMPLETE':
return {
...state,
fetching: false,
error: null,
products: [...action.payload.products]
}
case 'FETCH_PRODUCTS_ERROR':
return {
...state,
fetching: false,
error: action.payload
}
default:
return state;
}
}
The xSellsActions is a bit more involved. I start by making an object of the fetch options so I don't have to repeat them in every function (just need to change the method value appropriately). I create the following functions: get cross-sells for a single product, get every product's cross-sells, post a new cross-sell to a product, and remove a cross-sell from a product.
Promise.all came in very handy in the fetchAllXSells function. In this function I'm mapping over all the product Ids and calling the getContent function for each one. getContent performs a fetch, checks if we got any back (simply adding blank values otherwise), and stuffs the selected data into a giant object full of every product's cross-sells. When every fetch is finished, we can send off our dispatch so the reducer can update our state with the current xSells Object, and tell our app it's done fetching.
I've also abstracted some functions to stringify/parse data, update the fetch options, and return the appropriate fetch promise.
/* Fetch Option Object
-------------------------------------------------- */
const fetchOptions = {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'include',
}
/* Exported Functions
-------------------------------------------------- */
//== Fetch Cross-sells for a single product ==
export function fetchXSells(productId) {
fetchOptions.method = 'GET';
fetchOptions.body = null;
return function(dispatch) {
// Fetch begins, set 'fetching' to true and reset 'error' to null
dispatch({ type: 'FETCH_XSELLS_START' });
fetch(`/api/products/${productId}/metafields.json?namespace=xsells`, fetchOptions)
.then((res) => res.json())
.then((json) => dispatch({
type: 'FETCH_XSELLS_COMPLETE',
payload: { productId: productId, xSells: json }
}))
.catch((error) => dispatch({ type: 'FETCH_XSELLS_ERROR', payload: error}))
;
}
}
//== Fetch Cross-sells for all products ==
export function fetchAllXSells(productIds) {
fetchOptions.method = 'GET';
fetchOptions.body = null;
// All variables inside this function in order to access 'dispatch'
return function(dispatch) {
const allXSells = {};
// callback function Promise.all maps over to build a fresh xSells Object
const getContent = (productId) => {
return fetch(`/api/products/${productId}/metafields.json?namespace=xsells`, fetchOptions)
.then((res) => res.json())
.then((json) => {
// no metafields for 'xsells' namespace
let xSells = { metaId: '', value: [] };
if (json.metafields.length) {
// 'xsells' namespace fetch ensures it is the only data returned
const data = json.metafields[0];
xSells.metaId = data.id;
xSells.value = JSON.parse(data.value);
}
allXSells[productId] = xSells;
})
.catch((error) => dispatch({ type: 'FETCH_XSELLS_ERROR', payload: error}))
;
}
// Fetch begins, set 'fetching' to true and reset 'error' to null
dispatch({ type: 'FETCH_ALL_XSELLS_START' });
// Queue up all fetches and fire a single dispatch with the completed
// xSells Object
Promise.all(productIds.map(getContent))
.then(() => dispatch({ type: 'FETCH_ALL_XSELLS_COMPLETE', payload: allXSells }))
;
}
}
//== POST a new Cross-sell for a product ==
export function postXSell(productId, selectValue) {
return function(dispatch) {
dispatch({ type: 'POST_XSELL_START' });
postRequest(productId, selectValue).then(() => {
dispatch(fetchXSells(productId));
})
.catch((err) => {
dispatch({ type: 'POST_XSELL_ERROR', payload: error });
});
}
}
//== Delete a single Cross-sell for a product by the Cross-sell ID ==
export function deleteXSell(productId, newData, metaId) {
return function(dispatch) {
dispatch({ type: 'DELETE_XSELL_START' });
putRequest(productId, newData, metaId).then(() => {
dispatch(fetchXSells(productId));
})
.catch((err) => {
dispatch({ type: 'DELETE_XSELL_ERROR', payload: error })
});
}
}
/* Abstracted Request Functions
-------------------------------------------------- */
// POST request
function postRequest(productId, selectValue) {
const stringedValue = JSON.stringify(selectValue);
fetchOptions.method = 'POST';
fetchOptions.body = JSON.stringify({
metafield: {
namespace: "xsells",
key: "products",
value: stringedValue,
value_type: "string"
}
}, null, 2);
return fetch(`/api/products/${productId}/metafields.json`, fetchOptions);
}
// DELETE request
function deleteRequest(productId, metaId) {
fetchOptions.method = 'DELETE';
return fetch(`/api/products/${productId}/metafields/${metaId}.json`, fetchOptions);
}
// PUT request
function putRequest(productId, newData, metaId) {
const stringedValue = JSON.stringify(newData);
fetchOptions.method = 'PUT';
fetchOptions.body = JSON.stringify({
metafield: {
id: metaId,
value: stringedValue,
value_type: "string"
}
}, null, 2);
return fetch(`/api/products/${productId}/metafields/${metaId}.json`, fetchOptions);
}
This is the xSellsReducer. My xSells Object contains the xSell Metafield for every product in the store. When the app sends an update to the Shopify API, it re-fetches the data and updates the xSells Object to stay consistent.
I ran into a snag with their API because I needed multiple values for one metafield key, and they only accept primitives for values. I ended up stringifying an object before sending it to Shopify, which required me to be creative with how I'd render that through Liquid on the website. At this point I realized this was not going to be the simple, standard app I had imagined.
Another silly detail: I could not think of how to use a variable in an object I'm declaring. By just using a name with no quotations, you are adding that name as a key to the object, but I wanted the computed value. I finally came across the simple solution online to enclose the variable in brackets to compute it. Simple enough. I've commented out the excess cases, as they are simple value flips and stores.
const initState = {
xSells: {},
fetching: false,
error: null,
}
export default function(state = initState, action) {
switch(action.type) {
case 'FETCH_XSELLS_START':
return {
...state,
fetching: true,
error: null,
}
case 'FETCH_XSELLS_COMPLETE':
const product = action.payload.productId;
const data = action.payload.xSells.metafields[0];
// parse the value we get back and create a new xSell object
const xSells = { metaId: data.id, value: JSON.parse(data.value) }
// [product] is needed to compute the value rather than
// the word, for the object key
return {
...state,
fetching: false,
error: null,
xSells: {...state.xSells, [product]: xSells},
}
case 'FETCH_ALL_XSELLS_START':
// ...
case 'FETCH_ALL_XSELLS_COMPLETE':
// ...
case 'FETCH_XSELLS_ERROR':
// ...
case 'POST_XSELL_START':
// ...
case 'POST_XSELL_COMPLETE':
return {
...state,
fetching: false,
error: null,
xSells: {
...state.xSells,
[action.payload.productId]: [action.payload.xSells.metafield]
},
}
case 'POST_XSELL_ERROR':
// ...
default:
return state;
}
};
On to the components. The ProductsList is topmost in this App. It is responsible for dispatching the first fetch for products, and then fetching every cross-sell after it updates. In truth, I struggled to find the best practice for achieving this. It works, but I'm not sure if using the didUpdate lifecycle method is the best way.
There isn't much else to this component, other than looping over each product to render a ProductCard, and passing it a narrowed down Object for building the select.
class ProductsList extends Component {
componentWillMount() {
const { dispatch } = this.props;
// Fetch all products before mount
dispatch(fetchProducts());
}
componentDidUpdate() {
const { products } = this.props;
// Does the store have any products to display?
if (products.length) {
this.getXSells(); // Obtains each product's Cross-sells
}
}
getXSells() {
const { products, dispatch } = this.props;
const productIds = products.map((product) => product.id);
dispatch(fetchAllXSells(productIds));
}
renderProducts() {
const { products } = this.props;
// Create a simplified Products array for the select dropdown
const productsSelect = products.map((product) => {
const newObj = {};
newObj.label = product.title;
newObj.value = product.id;
return newObj;
});
return products.map((product) => (
<ProductCard key={product.id} product={product} productsSelect={productsSelect} />
));
}
render() {
if (this.props.fetching) {
return <h1>Loading . . .</h1>;
}
return (
<Layout>
{this.renderProducts()}
</Layout>
);
}
}
/* Redux State Mapping & Export
-------------------------------------------------- */
function mapStateToProps(state) {
return {
products: state.products.products,
fetching: state.products.fetching,
}
}
export default connect(mapStateToProps)(ProductsList);
The last snippet I'll show is the xSells component. It is the wrapper for each cross-sell line on a product, and contains the method to delete it. Inside the <Card > element I'm checking if there are cross-sells for the current product id, and then checking the length of its value in case it has been removed. This is because there is a difference between a product having a specific metafield, but blank (because we are stringifying an object we send), and no metafield at all. Combining the && check with a ternary operator isn't something I've seen anyone do, so perhaps it's a bit clunky, but it suited my purpose.
All the uppercase named elements are from Shopify's Polaris framework.
class XSells extends Component {
constructor(props) {
super(props);
this.removeXSell = this.removeXSell.bind(this);
}
removeXSell(xSellId) {
const { product, dispatch, xSells } = this.props;
const metaId = xSells[product.id].metaId;
const productXSells = xSells[product.id].value;
const newData = _.filter(productXSells, (item) => item.id !== xSellId);
dispatch(deleteXSell(product.id, newData, metaId));
}
findProductTitle(productId) {
const { products } = this.props;
const productObj = _.find(products, (product) => product.id === productId);
return productObj.title;
}
findProductHandle(productId) {
const { products } = this.props;
const productObj = _.find(products, (product) => product.id === productId);
return productObj.handle;
}
render() {
const { fetching, xSells, product } = this.props;
if (fetching) {
return <Spinner size="small" color="teal" />
} else {
return (
<Card>
{ xSells[product.id] && xSells[product.id].value.length ?
xSells[product.id].value.map((xSell, i) =>
<Card.Section key={i}>
<Stack >
<Stack.Item fill>
<p>{this.findProductTitle(xSell.id)}</p>
<Caption>ID: {xSell.id}</Caption>
</Stack.Item>
<Stack.Item>
<Button size="slim" icon="delete" destructive onClick={() => this.removeXSell(xSell.id)}>
</Button>
</Stack.Item>
</Stack>
</Card.Section>)
:
<p style={{padding: 10}}>No Cross-sells assigned to this product</p>
}
{ xSells[product.id] && xSells[product.id].value.length > 3 &&
<Banner
title="Cross-sell limit exceeded"
status="warning">
<p>No more than 3 Cross-sells will show on the Cart page</p>
</Banner>
}
</Card>
)
}
}
}
There were many more additions and alterations I wanted to make to this project, but I had a deadline and I was needed for another project after it was production ready. It was a great learning experience overall, and my supervisor was really excited for me to share my learnings of Redux and Polaris, as he'd been wanting to learn them himself for awhile. In the time since working on this, I have learned some better ways of doing things, but I'm always open to suggestions. If you see any flaws, feel free to email me the improvements you would make.