How I built a Contentful App combined with Commerce.js (IV)
The road so far...
It's time folks! The end of our journey through a Contentful App is here.
We have gathered a lot of information in the first three parts. We have seen the theory behind it, we understood the why for all of this and we saw an integration between Contentful and a third-party platform like Commerce.js.
Now it's time to connects all the pieces and finally see our main customization, which is the reason why I'm here writing stuff on a monitor š.
In this final episode, we are going to see our customized entry field. Our focus will be the research and selection of a product from Commerce.js.
And with a little bit of sadness in my eyes, let's start our last take š š„ŗ š¢.
The Concept
So what do we want to build? For our field, we want to choose which type of URL to save: Product, Category or Content Page.
Based on this choice, we open a modal showing all the related entries, in our use case, all the products from Commerce.js.
After the user selects a product, we save our field with some info. Since this field is of type JSON, we can structure it as we want and the user will see a card preview of the selected product and not the JSON Object itself.
Initial Setup
Inside our project, I created a lib folder where I'm going to save few files with constants and utility methods.
Ā
Constants
Our App has multiple purposes even if our focus is on the URL + product behavior.
I have defined few constants to separate these logics.
export const APPEARANCE_TYPES = {
URL: "url", // used in this demo
PRODUCT: "product",
PRODUCTS: "products",
CATEGORY: "category",
CATEGORIES: "categories",
};
export const TYPES = {
PRODUCT: "product", // used in this demo
CATEGORY: "category",
PAGE: "page",
};
Ā
Utilities
After that, we create some utility methods that will be used in the upcoming sections.
First, we need a function that retrieves a specific product from Commerce.js:
import Commerce from "@chec/commerce.js";
import { Product } from "@chec/commerce.js/types/product";
export const getProduct = async (
apiKey: string,
productId: string,
successCB?: (product: Product) => void,
failCB?: (e: any) => void
): Promise<Product | any> => {
try {
const commerce = new Commerce(apiKey);
const product = await commerce.products.retrieve(productId);
if (!successCB) return product;
successCB(product);
} catch (e) {
console.error(e);
if (!failCB) return e;
failCB(e);
}
};
and then we need the function that retrieves all the products:
import Commerce from "@chec/commerce.js";
import { ProductCollection } from "@chec/commerce.js/features/products";
export const getProducts = async (
apiKey: string,
successCB?: (products: ProductCollection) => void,
failCB?: (e: any) => void
): Promise<ProductCollection | any> => {
try {
const commerce = new Commerce(apiKey);
const products = await commerce.products.list();
if (!successCB) return products;
successCB(products);
} catch (e) {
console.error(e);
if (!failCB) return e;
failCB(e);
}
};
Both methods expect an API key as input and if you have read part 3 of this series, you know where it will come from š.
Custom components
We are not limited to using the files provided by Contentful, we can also create our own.
Ā
Product Card
The Product Card Component will be used both in the modal when we are looking for products to select, and also after the selection to show a nice UI instead of the JSON object saved on Contentful.
import { css } from "emotion";
import { Product } from "@chec/commerce.js/types/product";
import {
Card,
IconButton,
Flex,
Tag,
Heading,
HelpText,
} from "@contentful/forma-36-react-components";
interface IProductCard {
product?: Product;
showTag?: boolean;
onClickCard?: (product: Product) => void;
onClickIcon?: () => void;
}
export const ProductCard = (props: IProductCard) => {
const { product, showTag, onClickCard, onClickIcon } = props;
if (!product) return null;
return (
<Card
className={css({
height: "100%",
boxSizing: "border-box",
position: "relative",
})}
{...(onClickCard && { onClick: () => onClickCard(product) })}
>
{onClickIcon && (
<IconButton
iconProps={{ icon: "Close" }}
buttonType="muted"
className={css({
position: "absolute",
top: "3px",
right: "3px",
})}
onClick={onClickIcon}
/>
)}
<Flex alignItems="center">
{product.media && (
<div className={css({ marginRight: "20px", width: "100px" })}>
<img
className={css({ maxWidth: "100%" })}
src={product.media.source}
alt={product.name}
/>
</div>
)}
<Flex flexDirection="column">
{showTag && <Tag>product</Tag>}
<Heading>{product.name}</Heading>
<HelpText
className={css({
fontStyle: "italic",
fontSize: "12px",
marginBottom: "10px",
})}
>
SKU: {product.sku}
</HelpText>
</Flex>
</Flex>
</Card>
);
};
We are importing some UI components from Forma36 and a Product type definition from Commerce.js.
Our custom IProductCard interface defines the properties available for the component:
- product: this is the prop containing the whole product data coming from Commerce.js.
- showTag: this flag shows a tag that identifies this card as a product (this will be more clear later).
- onClickCard: this optional callback is used inside the products modal when the user selects a product. The function passes the product prop as a parameter.
- onClickIcon: this callback, when defined, shows an 'x' icon on the top right corner and is used when we want to clear our selection.
This card will have two possible layouts which we can see below:
The first one will be used inside the modal, the second in place of the JSON object when the product is already selected.
Ā
Product Wrapper
This component will be used inside the Dialog/Modal Location. It will contain all the products coming from Commerce.js.
Here the customer can click on a single card and select the product.
import { css } from "emotion";
import { Grid, GridItem } from "@contentful/forma-36-react-components";
import { useEffect, useState } from "react";
import { getProducts } from "../lib/commerce";
import { ProductCollection } from "@chec/commerce.js/features/products";
import { Product } from "@chec/commerce.js/types/product";
import { ProductCard } from "./ProductCard";
import { TYPES } from "../lib/Constants";
interface IProductWrapper {
publicKey: string;
onSelectProduct: (data: { id: string; type: string; url: string }) => void;
}
export const ProductWrapper = (props: IProductWrapper) => {
const { publicKey, onSelectProduct } = props;
const [productCollection, setProductCollection] =
useState<ProductCollection>();
useEffect(() => {
getProducts(publicKey, setProductCollection);
}, [publicKey]);
const onClickCard = (product: Product) => {
onSelectProduct({
id: product.id,
type: TYPES.PRODUCT,
url: `/p/${product.permalink}`,
});
};
if (!productCollection) return <p>Loading...</p>;
return (
<Grid columns={3} rowGap="spacingS" className={css({ margin: "20px 0" })}>
{productCollection.data.map((product) => (
<GridItem key={product.id}>
<ProductCard product={product} onClickCard={onClickCard} />
</GridItem>
))}
</Grid>
);
};
As usual, we are using UI components from Forma36, in this case, Grid and GridItem. We are using also the previous Product Card component in order to show all our dummy products.
If we look at the available props, we have:
- publicKey: this is the key used to call Commerce.js.
- onSelectProduct: this is the callback that will be called when the user clicks the card. It accepts an object as parameter. This object contains the data structure that will be saved on Contentful.
The component, thanks to the public key, calls Commerce.js with our utility method and saves products inside productCollection inner state. While is waiting for a response from Commerce.js, the component shows a simple Loading... paragraph to inform the reader.
We can see the UI below:
This is a simple demo with a simple UI and few products. Of course, if we want to do some improvements we could add an input field to search products and maybe also pagination if your Product Catalog is huge.
The only limit is your imagination (and your Business requirements/plan š)
Ā
Url Appearance Field
This is definitely the most complex component of the entire app.
This UI is rendered only when our instance parameter is set to 'URL'.
I'm going to analyze the file in the detail.
Ā
Url Appearance Field: imports
import { useState, useEffect } from "react";
import { css } from "emotion";
import { Product } from "@chec/commerce.js/types/product";
import { Flex, Button } from "@contentful/forma-36-react-components";
import { APPEARANCE_TYPES, TYPES } from "../lib/Constants";
import { getProduct } from "../lib/commerce";
import { ProductCard } from "./ProductCard";
import { FieldExtensionSDK } from "@contentful/field-editor-shared";
This is pretty clear, we are importing UI components, utilities, typescript types...š„±šŖ
Ā
Url Appearance Field: props interface
interface IUrlAppearanceField {
sdk: FieldExtensionSDK;
}
The interface is simple, we are expecting, as a prop, the SDK related to the field provided by Contentful.
Ā
Url Appearance Field: react hooks
Here we are extracting the Commerce.js public key from our parameters.installation
and we are defining some react hooks.
const { sdk } = props;
const { publicKey }: any = sdk.parameters.installation;
const [innerValue, setInnerValue] = useState(sdk.field.getValue());
const [product, setProduct] = useState<Product>();
useEffect(() => {
if (innerValue?.type === TYPES.PRODUCT) {
getProduct(publicKey, innerValue.id, setProduct);
}
}, [publicKey, innerValue]);
We have two useState hooks: the first is an inner state containing the value saved on Contentful, extracted with the getValue function provided by the SDK. The second contains the Commerce.js product.
The useEffect hook is called every time the innerValue changes and also on first load. The hook checks if the saved value is of type 'product' and if so, we call Commerce.js retrieving the full product, passing the innerValue.id which contains the id of a specific product.
Ā
Url Appearance Field: the UI
Let's jump to the return statement:
return (
<>
{product && (
<>
<ProductCard showTag product={product} onClickIcon={clearValue} />
<div
className={css({
margin: "20px 0",
borderTop: "1px solid #cfd9e0",
})}
/>
</>
)}
<Flex className={css({ marginTop: `${innerValue ? "0" : "10px"}` })}>
<Button
icon="ShoppingCart"
buttonType="muted"
className={css({ marginLeft: "10px", height: "2rem" })}
onClick={() => openDialog(TYPES.PRODUCT)}
>
Choose a Product
</Button>
</Flex>
</>
);
We are showing a Product Card and a separator when the user selects or has already selected a product.
The component has the showTag attribute set to true and the onClickIcon callback defined with a 'clearValue' function.
Finally, we have a Forma36 Button with a cart icon and a callback on the onClick event that opens a Dialog of type 'product'.
We can see the UI in the following screenshots:
As mentioned a million times š
we are focusing only on the product selection, the complete UI would instead be like this:
The light blue product badge lets the user understand immediately which type of URL has been saved on the field.
Ā
Url Appearance Field: clearValue callback
The clearValue function lets the user clear his selection by clicking on the 'x' icon on the card.
const clearValue = () => {
setProduct(undefined);
setInnerValue(undefined);
sdk.field.setValue(undefined);
};
We are cleaning our react states and we are using the setValue function provided by the SDK in order to reset the value also for Contentful.
Ā
Url Appearance Field: openDialog callback
The openDialog function is the core of the component.
It lets you open the Contentful Modal with the dialogs.openCurrentApp
method passing few parameters. Basically, this function moves the focus of your app into the Dialog Location.
Since is a Promise, it waits until you close the modal and after that, you have access to a response object.
If you remember, in the Product Wrapper component we defined an object composed of an id, type and URL. This is the object received from the Promise when we select a product (if we click on the x of the modal we receive an undefined object).
Let's see the implementation:
const openDialog = async (type: string) => {
const res = await sdk.dialogs.openCurrentApp({
position: "top",
minHeight: "75vh",
width: "fullWidth",
shouldCloseOnOverlayClick: true,
allowHeightOverflow: true,
title: `Search ${type === TYPES.CATEGORY ? " Categories" : "Products"}`,
parameters: { appearance: APPEARANCE_TYPES.URL, type },
});
if (res) {
setInnerValue(res);
sdk.field.setValue(res);
}
};
As you can see, if the res object is defined, it means that we have selected a product and we are saving this object into our react state but also into Contentful through the SDK.
Here you can see the entire code related to the product
import { useState, useEffect } from "react";
import { css } from "emotion";
import { Product } from "@chec/commerce.js/types/product";
import { Flex, Button } from "@contentful/forma-36-react-components";
import { APPEARANCE_TYPES, TYPES } from "../lib/Constants";
import { getProduct } from "../lib/commerce";
import { ProductCard } from "./ProductCard";
import { FieldExtensionSDK } from "@contentful/field-editor-shared";
interface IFieldUrl {
sdk: FieldExtensionSDK;
}
export const UrlAppearanceField = (props: IFieldUrl) => {
const { sdk } = props;
const { publicKey }: any = sdk.parameters.installation;
const [innerValue, setInnerValue] = useState(sdk.field.getValue());
const [product, setProduct] = useState<Product>();
useEffect(() => {
if (innerValue?.type === TYPES.PRODUCT) {
getProduct(publicKey, innerValue.id, setProduct);
}
}, [publicKey, innerValue]);
const openDialog = async (type: string) => {
const res = await sdk.dialogs.openCurrentApp({
position: "top",
minHeight: "75vh",
width: "fullWidth",
shouldCloseOnOverlayClick: true,
allowHeightOverflow: true,
title: `Search ${type === TYPES.CATEGORY ? " Categories" : "Products"}`,
parameters: { appearance: APPEARANCE_TYPES.URL, type },
});
if (res) {
setInnerValue(res);
sdk.field.setValue(res);
}
};
const clearValue = () => {
setProduct(undefined);
setInnerValue(undefined);
sdk.field.setValue(undefined);
};
return (
<>
{product && (
<>
<ProductCard showTag product={product} onClickIcon={clearValue} />
<div
className={css({
margin: "20px 0",
borderTop: "1px solid #cfd9e0",
})}
/>
</>
)}
<Flex className={css({ marginTop: `${innerValue ? "0" : "10px"}` })}>
<Button
icon="ShoppingCart"
buttonType="muted"
className={css({ marginLeft: "10px", height: "2rem" })}
onClick={() => openDialog(TYPES.PRODUCT)}
>
Choose a Product
</Button>
</Flex>
</>
);
};
Connecting dots
Now is the time to connect all the pieces of the puzzle.
In the previous section, we saw all our custom implementation and now we need to use the locations provided by Contentful and finish our implementation.
Our focus is on the Dialog.tsx
and Field.tsx
files. Let's start with the Modal.
Ā
Dialog Location
This file is used when we call the dialogs.openCurrentApp
function that we saw previously.
import { ModalContent } from "@contentful/forma-36-react-components";
import { DialogExtensionSDK } from "@contentful/app-sdk";
import { TYPES } from "../lib/Constants";
import { ProductWrapper } from "./ProductWrapper";
interface DialogProps {
sdk: DialogExtensionSDK;
}
const Dialog = (props: DialogProps) => {
const { type }: any = props.sdk.parameters.invocation;
const { publicKey }: any = props.sdk.parameters.installation;
return (
<>
<ModalContent>
{type === TYPES.PRODUCT && (
<ProductWrapper
publicKey={publicKey}
onSelectProduct={props.sdk.close}
/>
)}
{/* {type === TYPES.CATEGORY && (
<CategoryWrapper
publicKey={publicKey}
onSelectCategory={props.sdk.close}
/>
)} */}
</ModalContent>
</>
);
};
export default Dialog;
We have a specific Typescript type definition for the SDK which is now DialogExtensionSDK
. With this SDK, inside the parameters.invocation
we have access to the type attribute that we passed when we called the modal. This attribute lets us know what type of content to provide to the modal as you can see in the return statement.
To our Product Wrapper component, we are passing also the close
SDK callback that we'll be used when we select a product passing back the object to save on Contentful.
Ā
Field Location
Based on our instance parameter 'type' we render a UI or another, in our demo will be always the URL Appearance Field
import { useEffect } from "react";
import { FieldExtensionSDK } from "@contentful/app-sdk";
import { APPEARANCE_TYPES } from "../lib/Constants";
import { UrlAppearanceField } from "./UrlAppearanceField";
interface FieldProps {
sdk: FieldExtensionSDK;
}
const Field = (props: FieldProps) => {
const instanceProps: any = props.sdk.parameters.instance;
useEffect(() => {
props.sdk.window.startAutoResizer();
return () => props.sdk.window.stopAutoResizer();
}, [props]);
return (
<>
{instanceProps.type === APPEARANCE_TYPES.URL && (
<UrlAppearanceField sdk={props.sdk} />
)}
{/*
{instanceProps.type === APPEARANCE_TYPES.PRODUCT && (
<ProductAppearanceField sdk={props.sdk} />
)}
{instanceProps.type === APPEARANCE_TYPES.PRODUCTS && (
<ProductsAppearanceField sdk={props.sdk} />
)}
{instanceProps.type === APPEARANCE_TYPES.CATEGORY && (
<CategoryAppearanceField sdk={props.sdk} />
)}
{instanceProps.type === APPEARANCE_TYPES.CATEGORIES && (
<CategoriesAppearanceField sdk={props.sdk} />
)}
*/}
</>
);
};
export default Field;
Here, the only thing that I want to clarify is the useEffect hook. We are using the window.startAutoResizer
feature. This function updates the height of the iframe every time something happens (for example when we select or remove the product card). Doing this, we have always our wrapper height updated with no white spaces or scrollbars.
Little Demo
This is it, we finally completed our app š„³ š„³ š„³.
Here you can see a little demo:
Here instead the snapshot of the entry where you can see what is saved on Contentful:
{
"metadata": {
"tags": []
},
"sys": {
"space": {
"sys": {
"type": "Link",
"linkType": "Space",
"id": "xxx"
}
},
"id": "35MgIumMobPVc9qnCH0Xa0",
"type": "Entry",
"createdAt": "2021-10-02T16:55:24.957Z",
"updatedAt": "2021-10-03T10:11:46.157Z",
"environment": {
"sys": {
"id": "website",
"type": "Link",
"linkType": "Environment"
}
},
"revision": 5,
"contentType": {
"sys": {
"type": "Link",
"linkType": "ContentType",
"id": "testCommerceSelector"
}
},
"locale": "en-US"
},
"fields": {
"url": {
"id": "prod_RqEv5xXO2d5Zz4",
"type": "product",
"url": "/p/shoe05"
}
}
}
Deploying the app
Of course is not over yet š. We need to deploy our application, otherwise will be available only for us and with a local environment running.
Since our app is very small we don't need an external provider, we can deploy the app directly on Contentful, the process is pretty simple.
In our package.json file, we have scripts that build and upload the app for us.
The commands to run on the terminal are:
npm run build
npm run upload
Follow the instructions of the script and when finished we can see on Contentful our uploaded app.
As you can see now the frontend is not anymore our local environment but is hosted by Contentful š.
A step further...
It's not part of this series but I want to mention one further step that we can and should do for our app.
As we have seen, data about the product saved on Contentful, is minimal: product code and the permalink, nothing else. All other information for the product card is gathered in real-time by calling Commerce.js.
But what happens if a permalink changes or worse, the product on Commerce.js is deleted? Our website or mobile app that depends on Contentful data, can lead to a broken page.
Commerce.js provides a set of webhooks that we can configure. For example, there is a specific webhook when the product has been updated or another one if is deleted.
For each one, we need to provide a URL that Commerce.js can call every time something happens.
My demo website uses my Contentful data. It's built with Next.js and deployed on Vercel and I defined few API routes that listen to Commerce.js webhooks.
Every time a product changes, my API route receives the updated product from Commerce.js and thanks to the Contentful Content Management API I can update my content with the updated data or skip if nothing that I care about is changed.
Or for example, If I don't want to update my content automatically, when I receive the updated product I can send a notification and hopefully (š) there will be someone that will update manually the broken link(s) on Contentful.
Conclusion...
Wow...what a journey š¤Æ...probably not the best way to start my 'blog career' with a 4 articles series but it was fun, so much fun š.
Hope you enjoyed it too and if you are still here reading thank you very much, I appreciate it šš»āāļø.
Now you can leave or drop a comment or a like or a follow or whatever š...
And if with 'the road so far...' at the beginning of this page you get which TV series I'm referring to, carry on, drop another comment below and let me know.
See ya š¤ šŖ