DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 963,673 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
Cover image for How I built a Contentful App combined with Commerce.js (IV)
William Iommi
William Iommi

Posted on

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",
};
Enter fullscreen mode Exit fullscreen mode

Β 

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

Product Card - Modal UI

Product Card - Selected UI

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

Product Wrapper - Base UI

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";
Enter fullscreen mode Exit fullscreen mode

This is pretty clear, we are importing UI components, utilities, typescript types...πŸ₯±πŸ˜ͺ
Β 

Url Appearance Field: props interface

interface IUrlAppearanceField {
  sdk: FieldExtensionSDK;
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
Enter fullscreen mode Exit fullscreen mode

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:
Url Appearance Field | base UI

Url Appearance Field | product selected

As mentioned a million times πŸ˜… we are focusing only on the product selection, the complete UI would instead be like this:
Url Appearance Field | complete UI

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
    }
  };
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode


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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:
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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Follow the instructions of the script and when finished we can see on Contentful our uploaded app.

App Deployed

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 πŸ€™ πŸ’ͺ

Top comments (0)

🌚 Browsing with dark mode makes you a better developer by a factor of exactly 40.

It's a scientific fact.