DEV Community

loading...

Build a tech conf site with Gatsby + Crystalize (Headless GraphQL CMS)

studio_hungry profile image Richard Haines ・22 min read

This tutorial was originally posted on my blog richardhaines.dev

In this tutorial we will learn how to utilize the Crystallize graphql API as a headless CMS for our pretend tech conference website, The Conf Vault.

All the source code for this article can be found here: github.com/molebox/gatsby-crystallize-conf-example. Feel free to fork and play around with it, it can often times help to have the source code open when following a tutorial.


I've been really impressed with what Crystallize has to offer, at first it was quite the mind shift thinking about modelling my data but I really like the process of using Figma to brainstorm the models then being able to directly translate them into actual models in the Crystallize UI.

Crystallize provide the tools with which to visually present content and I found the whole process much more aligned with how I tend to think about projects before starting them. Due to the nature of the composable shapes, we as creators can put together feature rich stories with the aim of driving home our brands story, be that our personal brand or business.

Although mainly marketed as an ecommerce PIM, Crystallize is certainly capable of much more, let's take a look...

We will learn:

  • Why Crystallize?
  • Content modelling (with Figma 🤯)
  • Querying and pulling data into a Gatsby site with Apollo
  • Deploy to Netlify and set up webhooks!
  • BONUS: Make it pretty! Add some gsap animations, some colors, throw some box shadows on it... 🤗

This article assumes prior knowledge of React and the Jamstack ecosystem.

Why Crystallize?

As a Jamstack developer you are most likely familier with the concept of the headless Content Management System (CMS), a place for you to enter and store data from which a frontend will request and use it. Differentiating between them mostly comes down to how you want to interact with your stored data, via a GUI or CLI, and how to access that data, via REST or Graphql (gql) endpoints.

Marketing itself as a super fast headless CMS for Product Information Management (PIM, we're racking up those abbreviations!), it aims to enable the user to combine rich story telling, structured content and ecommerce as a single solution. But it doesn't only have to used for ecommerce solutions. Crystallize is flexible enough so that we can utilize its structured content models and create anything we like, then using it's graphql API we can access our stored data from any device, be that computer or mobile.

The UI is also super easy to hand off to a client so that they can enter in data themselves, which is a massive plus when considering which CMS to go with while working with clients.

Content Modelling

When we whiteboard or brainstorm ideas they are very rarely linear, they don't tend to fit in square boxes, at least that is, until we manipulate those ideas to fit a given structure, one provided to us by our choice of CMS for example. Of course, a totally generic solution to modelling our content would also be very time consuming for a user to put together. Give them a set of premade tools with just the right amount of generics however and they can create what they want, in whatever shapes they please.

The content model serves as documentation and overview and connects information architects, designers, developers and business stakeholders.

The fine folks at Crystallize have created a design system using Figma and given everyone access to it via a Figma file you can download. I put together a model for our tech conf site which you can download here.

title=""
url="file/gywqAn9uh3J2vjwcfIOdVr/The-Conf-Vault-Content-Model?node-id=0%3A1"
/>

Looking at the content model, we have 3 shapes, Event, Schedule and Speaker. These are in the format of Documents. Each one is comprised of components which make up the structure of that model. The Event shape has a relationship with both the schedule and speaker shapes. This is because an event has both a schedule and speakers. The schedule shape also has a relationship with the speakers shape. These relationships will allow us to query on a single node but access it's corrasponding relationship nodes. For example, if we query for an event, we will in turn be able to access the speakers at that event.

Note that the modelling you do in Figma can't be exported and used in the Crystallize UI, you will have to manually re-create the models.

Show me the crystals... 💎

Head over to crystallize.com and create a new account, once in create a new tenent and then you will be presented with a page similar to the following:

Logged into Crystallize screenshot

On the left hand side you can open the menu to reveal your options. With your Figma file open too, start creating the shapes and their components. Begin with the folders. 3 folders should do the trick, Speakers, Conferences and Schedules. Now create the 3 document shapes, Event, Schedule and Speaker. Each of our document shapes will be made up of components, following our content model in Figma, add the components to the newly created shapes.

Once done open the catalogue tab (the one at the top) and inside the Conference folder create a new document of type Event.

An Event

Logged into Crystallize screenshot

Don't worry about adding anything to the schedule relationship just yet, we'll need to create a schedule first for that to make any sense! The same applies to the speakers relationships.

Once you have created all your events do the same for the speakers and schedules. Now the schedules are done you can add the speaker relations to those, then coming back to the events, you can add both the schedule and speaker relations, and the circle of life is complete!

A Speaker

Logged into Crystallize screenshot

A Schedule

Logged into Crystallize screenshot

Fetching data using Apollo Client

Being a Jamstack dev there are quite a few solutions to the age old question of "Which frontend should I use for my headless CMS...?" We'll be going with Gatsby today. I prefer to spin up Gatsby sites from an empty folder, if you are well versed then feel free to use a starter or template. We'll be needing some additional packages to those that form a basic Gatsby site, from the command line (I will be using yarn but npm is fine too) add the following packages:

yarn add @apollo/client isomorphic-fetch
Enter fullscreen mode Exit fullscreen mode

There are a couple of ways we could connect our Cystallize API with our Gatsby site. Crystallize has a gatsby boilerplate which uses the gatsby-source-graphql plugin, I would have expected there to be a source plugin for sourcing data from Crystallize which would have meant abstracting away from the gatsby-source-graphql and transforming the source nodes. Instead, we'll be super on trend and use Apollo to interact with and fetch our data.

wrap-root.js

In Gatsby there are two files that can be created and used in order to access certain points of the build process. We'll be creating a third file which will be imported into both. This is purely a personal choice that reduces code duplication, though it has become somewhat of a standard in the Gatsby community.

const React = require("react");
// We need this as fetch only runs in the browser
const fetch = require("isomorphic-fetch");
const {
  ApolloProvider,
  ApolloClient,
  createHttpLink,
  InMemoryCache,
} = require("@apollo/client");

// create the http link to fetch the gql results
const httpLink = createHttpLink({
  uri: "https://api.crystallize.com/rich-haines/catalogue",
  fetch,
});

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: httpLink,
  fetch,
});

export const wrapRootElement = ({ element }) => (
  <ApolloProvider client={client}>{element}</ApolloProvider>
);
Enter fullscreen mode Exit fullscreen mode

We create a http link to our gql endpoint and pass it to the Apollo client, before passing the client to the provider and wrapping our app.

This file will be imported into and exported from both the gatsby-ssr.js and gatsby-browser.js files like so:

import { wrapRootElement as wrap } from "./wrap-root";

export const wrapRootElement = wrap;
Enter fullscreen mode Exit fullscreen mode

Let's now add some scripts to our package.json so that we can run our site.

{
  "name": "gatsby-conf-example",
  "version": "1.0.0",
  "main": "index.js",
  "author": "Rich Haines",
  "license": "MIT",
  "scripts": {
    "dev": "gatsby develop",
    "build": "gatsby build",
    "clean": "gatsby clean",
    "z": "gatsby clean && gatsby develop",
    "pretty": "prettier --write \"src/**/*js\""
  },
  "dependencies": {
    ...deps
  },
  "devDependencies": {
    ...devDeps
  }
}
Enter fullscreen mode Exit fullscreen mode

Often times when developing Gatsby sites you will need to remove the cache, setting up a simple script to both clear the cache and run our site in gatsby develop mode will save time and headaches later. hence yarn z, the name is arbitrary.

Show me the data!

Now that we have Apollo setup we can head back over to the Crystallize UI and navigate to the Catalogue Explorer tab which can be found in the left tab menu. Click Fetch tree at root and run the query. You should see your 3 folders returned. If we inspect the query on the left of the explorer we can see that it's in fact 1 query with many fragments. These fragments split up the requests into bite size chunks that can then be spread into other fragments or the query.

A neat feature which I really like with Crystallize is the ability to test out queries directly from the shape, with provided base query and fragments to get you going. If you head to your catalogue and open up an event, then click the gql symbol which sits along the top bar an explorer will open up, it should look something like this:

Logged into Crystallize screenshot

This is nice and allows you to play about with different fragments and see what you would get back from your query should you use it in production. Not content with offering 2 different ways to test our queries, Crystallize provides a 3rd. A url with your tenent id which looks like the following: https://api.crystallize.com/your-tenent-id-here/catalogue.

This is a clean slate with tabs to save each query. From whatever gql explorer you choose, open the Docs tab located on the right. From here you can see what you can query and how each interface is nested or relates to another. Click catalogue and you can see that it returns a Item, when we click the Item we can see all of the properties we can query for.

The interesting part of this is the children property, which itself returns an Item. This nesting goes as far as your data is nested but is powerful and enables us to query nested children without having to specify specific properties.

For our index/home page we will be querying for the root paths to our 3 folders, these will be passed on to components which will use that path to themselves query for specific data.

GetRootPaths

query GetRootPaths {
  catalogue(language: "en", path: "/") {
    children {
      path
      shape {
        name
      }
      children {
        path
        shape {
          name
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We set the path param to that of the root directory, that is, the tenent. From here we ask for the first child and that's first child. So that is 2 levels deep. We request the path and the name of the shape. We know that our 3 shapes are called Conferences, Speakers and Schedules. Those should be our top level data types. Then we would expect to see the paths and shapes of the documents within the 3 folders. What is returned is the following:

{
  "data": {
    "catalogue": {
      "children": [
        {
          "path": "/conferences",
          "shape": {
            "name": "Conferences"
          },
          "children": [
            {
              "path": "/conferences/oh-my-dayz",
              "shape": {
                "name": "Event"
              }
            },
            {
              "path": "/conferences/crystal-conf-yeah",
              "shape": {
                "name": "Event"
              }
            }
          ]
        },
        {
          "path": "/speakers",
          "shape": {
            "name": "Speakers"
          },
          "children": [
            {
              "path": "/speakers/speaker",
              "shape": {
                "name": "Speaker"
              }
            },
            {
              "path": "/speakers/speaker-1",
              "shape": {
                "name": "Speaker"
              }
            },
            {
              "path": "/speakers/speaker-2",
              "shape": {
                "name": "Speaker"
              }
            }
          ]
        },
        {
          "path": "/schedules",
          "shape": {
            "name": "Schedules"
          },
          "children": [
            {
              "path": "/schedules/oh-my-dayz-schedule",
              "shape": {
                "name": "Schedule"
              }
            },
            {
              "path": "/schedules/crystal-conf-schedule",
              "shape": {
                "name": "Schedule"
              }
            }
          ]
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Sure enough we see the expected data. Let's move back to the frontend and add this query to our code.

Open the index.js file located in the pages folder of your Gatsby project.

index.js

import React from "react";
import { useQuery, gql } from "@apollo/client";

export default function Index() {
  const { loading, error, data } = useQuery(GET_ROOT_PATHS);

  if (loading) {
    return <h1>loading....</h1>;
  }
  if (error) {
    return <h1>error....</h1>;
  }

  const conferencePaths = data.catalogue.children[0].children.map(
    (node) => node.path
  );

  return (
    <div>
      {conferencePaths.map((path, index) => (
        <p key={index}>{path}</p>
      ))}
    </div>
  );
}

const GET_ROOT_PATHS = gql`
  query GetRootPaths {
    catalogue(language: "en", path: "/") {
      children {
        path
        shape {
          name
        }
        children {
          path
          shape {
            name
          }
        }
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

Apollo provides us with a lovely way to query and handle our data. We pass our query into the useQuery hook, in return we get 2 states (loading, error) and our data. We do a simple check to makes sure our data isn't loading or has an error then we filter out the conference paths and simply display them on the screen. We'll be coming back to this page soon, but let's first use a query that accepts some parameters.

The Event

We'll pass each conference path down to an event component which in turn will use that path as a query param to request data about that event. Let's see how that looks in practice. In your components folder, inside the src folder (assuming you set your project up this way) create a new file and name it event.js

event.js

import React from "react";
import { useQuery, gql } from "@apollo/client";

const Event = ({ path }) => {
  const { loading, error, data } = useQuery(GET_CONFERENCE, {
    variables: {
      path: path,
    },
  });

  if (loading) {
    return <h1>loading....</h1>;
  }
  if (error) {
    return <h1>error....</h1>;
  }

  let title = data.catalogue.components[0].content.text;
  let logo = data.catalogue.components[1].content.images[0].variants[0].url;
  let codeOfConduct = data.catalogue.components[2].content.json;
  let speakersPath = data.catalogue.components[4].content.items.map(
    (node) => node.path
  );
  let schedulePath = data.catalogue.components[3].content.items[0].path;

  return (
    <div>
      <h1>{title}</h1>
      <img src={logo} />
      {speakersPath.map((path, index) => (
        <Speaker key={index} path={path} />
      ))}
      <Schedule path={schedulePath} />
      <CoD cod={codeOfConduct} />
    </div>
  );
};

export default Event;

const GET_CONFERENCE = gql`
  query GetConference($path: String!) {
    catalogue(language: "en", path: $path) {
      name
      path
      components {
        content {
          ...richText
          ...imageContent
          ...singleLineText
          ...paragraphsCollection
          ...propertiesTable
          ...relations
        }
      }
    }
  }

  fragment singleLineText on SingleLineContent {
    text
  }

  fragment richText on RichTextContent {
    json
    html
    plainText
  }

  fragment image on Image {
    url
    altText
    key
    variants {
      url
      width
      key
    }
  }

  fragment imageContent on ImageContent {
    images {
      ...image
    }
  }

  fragment paragraphsCollection on ParagraphCollectionContent {
    paragraphs {
      title {
        ...singleLineText
      }
      body {
        ...richText
      }
      images {
        ...image
      }
    }
  }

  fragment propertiesTable on PropertiesTableContent {
    sections {
      ... on PropertiesTableSection {
        title
        properties {
          key
          value
        }
      }
    }
  }

  fragment relations on ItemRelationsContent {
    items {
      name
      path
      components {
        content {
          ...richText
          ...imageContent
          ...singleLineText
          ...paragraphsCollection
        }
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

The query was put together in the gql explorer, the order of the fragments is important as some of them rely on one another and they can't be defined before they are used. The basic logic behind the query is that we pass in a path to a conference from which we want to recieve back the components that make up the data for that shape. The components are split into fragments so that our query doesn't become bloated. Notice the relations fragment. It returns the same data as our query plus it's own path and name. Nearly recursive, of course, to understand recursion one must first understand recursion....

Our Speaker and Schedule components follow much the same way of thinking. The CoD and indeed some other components, uses a complimentory library supplied by Crystallize to help with displaying it's rich text data, which is returned as either html , json or plain text. Let's install it and learn how to use it.

yarn add @crystallize/content-transformer
Enter fullscreen mode Exit fullscreen mode

Now in our components folder create a new file named content-transform.js

content-transform.js

import React from "react";
import CrystallizeContent from "@crystallize/content-transformer/react";

const ContentTransform = (props) => {
  const overrides = {
    paragraph({ metadata, renderNode, ...rest }) {
      return <p style={{ fontSize: props.fontSize }}>{renderNode(rest)}</p>;
    },
  };

  return <CrystallizeContent {...props} overrides={overrides} />;
};

export default ContentTransform;
Enter fullscreen mode Exit fullscreen mode

This package basically allows us to pass in overrides for how it displays certain elements. In the above example, taken from our app, the paragraph tag is overriden with the font size prop passed in. In practice this is used like so:

CoD

import React from "react";
import ContentTransform from "./content-transform";

const CoD = ({ cod }) => {
  return (
    <div>
      <ContentTransform {...cod} />
    </div>
  );
};

export default CoD;
Enter fullscreen mode Exit fullscreen mode

And thats it. If we were to pass in the font size prop we could do so like this:

<ContentTransform fontSize="100px" {...cod} />
Enter fullscreen mode Exit fullscreen mode

It's an elegant way to help display rich text data.

As mentioned, our Speaker and Schedule components are much the same. Let's take them both at the same time.

speaker.js

import React from "react";
import { useQuery, gql } from "@apollo/client";
import ContentTransform from "./content-transform";

const Speaker = ({ path }) => {
  const { loading, error, data } = useQuery(GET_SPEAKER, {
    variables: {
      path: path,
    },
  });

  if (loading) {
    return <h1>loading...</h1>;
  }
  if (error) {
    return <h1>error...</h1>;
  }

  let image = data.catalogue.components[1].content.images[0].variants[0].url;
  let name = data.catalogue.components[0].content.json;
  let company = data.catalogue.components[2].content.text;
  let bio = data.catalogue.components[3].content.json;
  let twitter = data.catalogue.components[4].content.text;

  return (
    <div>
      <img src={image} />
      <ContentTransform fontSize="xl" {...name} />
      <p>{company}</p>
      <ContentTransform {...bio} />
      <p>{twitter}</p>
    </div>
  );
};

export default Speaker;

const GET_SPEAKER = gql`
  query GetSpeaker($path: String!) {
    catalogue(language: "en", path: $path) {
      ...item
      name
      components {
        content {
          ...richText
          ...singleLineText
          ...imageContent
        }
      }
    }
  }

  fragment item on Item {
    name
    type
    path
    children {
      name
    }
  }

  fragment singleLineText on SingleLineContent {
    text
  }

  fragment richText on RichTextContent {
    json
    html
    plainText
  }

  fragment image on Image {
    url
    altText
    key
    variants {
      url
      width
      key
    }
  }

  fragment imageContent on ImageContent {
    images {
      ...image
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

schedule.js

import React from "react";
import { useQuery, gql } from "@apollo/client";

const Schedule = ({ path }) => {
  const { loading, error, data } = useQuery(GET_SCHEDULE, {
    variables: {
      path: path,
    },
  });

  if (loading) {
    return <h1>loading...</h1>;
  }
  if (error) {
    return <h1>error...</h1>;
  }

  let title = data.catalogue.components[0].content.sections[0].title;
  let schedule = data.catalogue.components[0].content.sections[0].properties;

  return (
    <div>
      <h1>{title}</h1>
      <table cellPadding={6}>
        <thead>
          <tr>
            <th>
              <p>Speaker</p>
            </th>
            <th>
              <p>Subject...</p>
            </th>
          </tr>
        </thead>

        <tbody>
          {schedule.map((node, index) => (
            <tr key={index}>
              <td>
                <p>{node.key}</p>
              </td>
              <td>
                <p>{node.value}</p>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

export default Schedule;

const GET_SCHEDULE = gql`
  query GetSchedule($path: String!) {
    catalogue(language: "en", path: $path) {
      ...item
      components {
        content {
          ...propertiesTable
        }
      }
    }
  }

  fragment item on Item {
    name
    type
    path
    children {
      name
    }
  }

  fragment propertiesTable on PropertiesTableContent {
    sections {
      ... on PropertiesTableSection {
        title
        properties {
          key
          value
        }
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

Our schedule component makes use of the properties table in the Crystallize backend. This is translated to key value pairs which work perfectly when used in an actual HTML table.

Deploy when content updates using webhooks

Our site isn't much to look at, in fact it's downright ugly! But we'll worry about that later, let's first get this baby deployed and setup a web hook so that our static site rebuilds every time we publish changes from our Crystallize backend.

This section assumes you have a Netlify account setup, if not create an account if you wish to follow along with this section.

Create an netlify.toml file at the projects root.

[build]
    command = "yarn build"
    functions = "functions"
    publish = "public"
Enter fullscreen mode Exit fullscreen mode

Next, create a new site from the repository you created earlier, I hope you have been commiting your code! Netlify will use the settings from the .toml file we just created. In the netlify dashboard head to the Deploys tab and then the Deploy Settings, scroll down until you find the build hooks section. Add a new build hook, naming it whatever you like, perhaps NETLIFY_BUILD_ON_PUBLISH makes the most sense as that's what it's going to do. You will be presented with a url, copy it to the clipboard and head on over to the Crystallize UI. From the tabs on the left click the little Captain Hook icon then add a new web hook

The Crystallize webhook screen

Here we have selected publish as the event we want to trigger our build hook. Paste the url you copied from the netlify dahsboard into the URL section and change it from GET to POST, then hit save. Now make a small change to your data, add a shape, remove a full stop, whatever. Then open the netlify dahsboard, go to the deploy section and watch as your site rebuilds!

BONUS!

Our site quite frankly, looks terrible. Let's straighten that out. I'm going to show the code for each component plus a few extras, they each use Chakra-UI which allows inline styling via props.

Let's install some additional packages

yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion gsap gatsby-plugin-google-fonts react-rough-notation
yarn add prettier -D
Enter fullscreen mode Exit fullscreen mode

Unfortunately Chakra requires us to install framer motion (as of v1) even though we will be adding some animations using gsap. I can forgive this as working with Chakra will enable us to utilize performant and accessibility first components and speed up our development time when creating our UI.

Inside the src folder create a new file called theme.js this is where we will define our apps colors, fonts and font sizes.

theme.js

import { extendTheme } from "@chakra-ui/react";

export const theme = extendTheme({
  styles: {
    global: {
      body: {
        visibility: "hidden",
      },
    },
  },
  fonts: {
    heading: "Open Sans",
    body: "Jost",
  },
  fontSizes: {
    xs: "12px",
    sm: "14px",
    md: "16px",
    lg: "18px",
    xl: "20px",
    "2xl": "24px",
    "3xl": "28px",
    "4xl": "36px",
    "5xl": "74px",
    "6xl": "100px",
    "7xl": "130px",
  },
  colors: {
    brand: {
      bg: "#008ca5",
      offBlack: "#000213",
      offWhite: "#f6f8fa",
      accent: "#e93f79",
    },
  },
  config: {
    useSystemColorMode: false,
    initialColorMode: "light",
  },
});
Enter fullscreen mode Exit fullscreen mode

Notice we have set the bodies visibility to hidden? We will be using some gsap animations soon and this will stop our animations from flashing on page mount.

Now we will need to add the ChakraProvider to the wrap-root.js file, import the theme and pass it into the ChakraProvider like so:

export const wrapRootElement = ({ element }) => (
  <ChakraProvider resetCSS theme={theme}> // <===== HERE
    <ApolloProvider client={client}>{element}</ApolloProvider>
  </ChakraProvider>
);
Enter fullscreen mode Exit fullscreen mode

Next we want to add a way to access our fonts from google. We have already installed the package so create a gatsby-config.js file and add the following:

module.exports = {
  plugins: [
    {
      resolve: `gatsby-plugin-google-fonts`,
      options: {
        fonts: [
          `Jost`,
          `Open Sans`,
          `source sans pro\:300,400,400i,700`, // you can also specify font weights and styles
        ],
        display: "swap",
      },
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

It's important to add the display: 'swap' as this will swap out our font for the system font while the page loads, helping with performance.

In the components folder create two new files, layout.js and section.js . Then create a new folder called state and add loading.js and error.js files to it.

layout.js

import React from "react";
import { Flex, Box } from "@chakra-ui/react";

const Layout = ({ children }) => {
  return (
    <Box bgColor="brand.bg" h="100%" minH="100vh" w="100%" overflow="hidden">
      <Flex direction="column" m="0 auto" bgColor="brand.bg" p={3}>
        {children}
      </Flex>
    </Box>
  );
};

export default Layout;
Enter fullscreen mode Exit fullscreen mode

section.js

import { Flex } from "@chakra-ui/react";
import React from "react";

const Section = ({ children, fullPage }) => {
  return (
    <Flex
      as="section"
      h={`${fullPage ? "100vh" : "100%"}`}
      direction="column"
      maxW="1440px"
      m="0 auto"
    >
      {children}
    </Flex>
  );
};

export default Section;
Enter fullscreen mode Exit fullscreen mode

state/loading.js

import React from "react";
import Section from "./../section";
import { Flex, Spinner } from "@chakra-ui/react";

const Loading = () => {
  return (
    <Section>
      <Flex justify="center" align="center">
        <Spinner size="xl" />
      </Flex>
    </Section>
  );
};

export default Loading;
Enter fullscreen mode Exit fullscreen mode

state/error.js

import React from "react";
import Section from "../section";
import { Flex, Text } from "@chakra-ui/react";

const Error = () => {
  return (
    <Section>
      <Flex justify="center" align="center">
        <Text>You broke it! Try turning it on and off again...</Text>
      </Flex>
    </Section>
  );
};

export default Error;
Enter fullscreen mode Exit fullscreen mode

At the moment we have a bunch of files just hanging loose in the components folder, let's organize them into something more manageble. Create a event folder and a hero folder. Move the event.js , schedule.js, cod.js, content-transform.js and speaker.js files to the event folder. Still inside the event folder create container.js , heading.js and buy-ticket-button.js

container.js

import React from "react";
import { Box } from "@chakra-ui/react";

const Container = ({ children, ...rest }) => (
  <Box my={6} {...rest}>
    {children}
  </Box>
);

export default Container;
Enter fullscreen mode Exit fullscreen mode

heading.js

import React from "react";
import { Text } from "@chakra-ui/react";

const Heading = ({ children }) => (
  <Text fontSize="2xl" m={0} textAlign="center" fontFamily="heading">
    {children}
  </Text>
);

export default Heading;
Enter fullscreen mode Exit fullscreen mode

buy-ticket-button.js

import React from "react";
import { Button } from "@chakra-ui/react";

const BuyTicketButton = () => {
  return (
    <Button
      bg="brand.accent"
      h="70px"
      w="250px"
      px={2}
      transition="all .25s ease-in-out"
      boxShadow="-5px 5px #000"
      borderRadius={0}
      variant="outline"
      textTransform="uppercase"
      fontSize="lg"
      _active={{
        boxShadow: "-2px 2px #000",
        transform: "translate(-2px, 2px)",
      }}
      _hover={{
        boxShadow: "-2px 2px #000",
        transform: "translate(-2px, 2px)",
      }}
    >
      Buy a Ticket!
    </Button>
  );
};

export default BuyTicketButton;
Enter fullscreen mode Exit fullscreen mode

Cool. Now let's update our previously created components.

event.js

import React from "react";
import { useQuery, gql } from "@apollo/client";
import Section from "../section";
import { Flex, Text, Grid, Image, Box } from "@chakra-ui/react";
import Error from "../state/error";
import Loading from "../state/loading";
import Speaker from "./speaker";
import Schedule from "./schedule";
import CoD from "./cod";
import BuyTicketButton from "./buy-ticket-button";
import Container from "./container";
import Heading from './heading';

const Event = ({ path }) => {
  const { loading, error, data } = useQuery(GET_CONFERENCE, {
    variables: {
      path: path,
    },
  });

  if (loading) {
    return <Loading />;
  }
  if (error) {
    return <Error />;
  }

  let title = data.catalogue.components[0].content.text;
  let logo = data.catalogue.components[1].content.images[0].variants[0].url;
  let codeOfConduct = data.catalogue.components[2].content.json;
  let speakersPath = data.catalogue.components[4].content.items.map(
    (node) => node.path
  );
  let schedulePath = data.catalogue.components[3].content.items[0].path;

  return (
    <Section>
      <Grid
        templateColumns="10% 1fr 10%"
        autoRows="auto"
        w={["95%", "1440px"]}
        m="2em auto"
        bgColor="brand.offWhite"
        gap={5}
        boxShadow="-3px 3px #000"
      >
        <Flex
          gridColumn={2}
          gridRow={1}
          justify="space-evenly"
          align="center"
        >
          <Box
            bgColor="brand.offBlack"
            p={6}
            lineHeight={1}
            transform="rotate(-5deg)"
            boxShadow="-3px 3px #e93f79"
          >
            <Text
              fontFamily="heading"
              fontSize={["xl", "5xl"]}
              color="brand.offWhite"
              fontWeight={700}
            >
              {title}
            </Text>
          </Box>
          <Image src={logo} boxSize={100} boxShadow="-3px 3px #e93f79" />
        </Flex>
        <Container gridRow={2} gridColumn={2} border="solid 1px" p={2} boxShadow="-3px 3px #000">
          <Heading>The Speakers</Heading>
          <Flex
            gridRow={2}
            gridColumn={2}
            p={2}
            justify="center"
            align="center"
            wrap="wrap"
            m="1em auto"
            maxW="1000px"
          >
            {speakersPath.map((path, index) => (
              <Speaker key={index} path={path} />
            ))}
          </Flex>
        </Container>
        <Container gridRow={3} gridColumn={2}>
          <Schedule path={schedulePath} />
        </Container>
        <Container gridRow={4} gridColumn={2}>
          <CoD cod={codeOfConduct} />
        </Container>
        <Container mx="auto" mb={6} gridRow={5} gridColumn={2}>
          <BuyTicketButton />
        </Container>
      </Grid>
    </Section>
  );
};

...query
Enter fullscreen mode Exit fullscreen mode

schedule.js

import React from "react";
import { Box, Flex, Text } from "@chakra-ui/react";
import Loading from "../state/loading";
import Error from "../state/error";
import { useQuery, gql } from "@apollo/client";
import Heading from "./heading";
import Container from "./container";

const Schedule = ({ path }) => {
  const { loading, error, data } = useQuery(GET_SCHEDULE, {
    variables: {
      path: path,
    },
  });

  if (loading) {
    return <Loading />;
  }
  if (error) {
    return <Error />;
  }

  let title = data.catalogue.components[0].content.sections[0].title;
  let schedule = data.catalogue.components[0].content.sections[0].properties;

  return (
    <Flex
      justify="center"
      p={2}
      mx="auto"
      w={["300px", "1000px"]}
      direction="column"
    >
      <Container>
        <Heading>{title}</Heading>
      </Container>
      <Box as="table" cellPadding={6} mb={6}>
        <Box as="thead">
          <Box as="tr">
            <Box as="th" align="left" colSpan="-1">
              <Text
                fontSize={["md", "xl"]}
                fontWeight={600}
                fontFamily="heading"
              >
                Speaker
              </Text>
            </Box>
            <Box as="th" align="left">
              <Text
                fontSize={["md", "xl"]}
                fontWeight={600}
                fontFamily="heading"
              >
                Subject...
              </Text>
            </Box>
          </Box>
        </Box>

        <Box as="tbody">
          {schedule.map((node, index) => (
            <Box key={index} as="tr">
              <Box as="td" borderBottom="solid 1px" borderLeft="solid 1px">
                <Text fontSize={["md", "xl"]} fontFamily="body">
                  {node.key}
                </Text>
              </Box>
              <Box as="td" borderBottom="solid 1px" borderLeft="solid 1px">
                <Text fontSize={["md", "xl"]} fontFamily="body">
                  {node.value}
                </Text>
              </Box>
            </Box>
          ))}
        </Box>
      </Box>
    </Flex>
  );
};
Enter fullscreen mode Exit fullscreen mode

Most Chakra component are based on the Box component, which itself is polymorphic and can be changed to represent any semantic html element. So in this case we have used it to re-create the html table. The upside of this is that we are able to use the Chakra props while keeping our code semantically correct.

content-transform.js

import React from "react";
import CrystallizeContent from "@crystallize/content-transformer/react";
import { Text } from "@chakra-ui/react";

const ContentTransform = (props) => {
  const overrides = {
    paragraph({ metadata, renderNode, ...rest }) {
      return (
        <Text fontSize={props.fontSize} my={2}>
          {renderNode(rest)}
        </Text>
      );
    },
  };

  return <CrystallizeContent {...props} overrides={overrides} />;
};

export default ContentTransform;
Enter fullscreen mode Exit fullscreen mode

speaker.js

import { Flex, Image, Text, Box } from "@chakra-ui/react";
import React from "react";
import { useQuery, gql } from "@apollo/client";
import Loading from "../state/loading";
import Error from "../state/error";
import ContentTransform from "./content-transform";
import { RoughNotation } from "react-rough-notation";

const Speaker = ({ path }) => {
  const { loading, error, data } = useQuery(GET_SPEAKER, {
    variables: {
      path: path,
    },
  });

  if (loading) {
    return <Loading />;
  }
  if (error) {
    return <Error />;
  }

  let image = data.catalogue.components[1].content.images[0].variants[0].url;
  let name = data.catalogue.components[0].content.json;
  let company = data.catalogue.components[2].content.text;
  let bio = data.catalogue.components[3].content.json;
  let twitter = data.catalogue.components[4].content.text;

  return (
    <Flex direction="column" p={2} align="center" minH="300px">
      <Image mb={3} src={image} borderRadius="full" boxSize={100} />
      <RoughNotation
        type="highlight"
        strokeWidth={2}
        padding={0}
        show={true}
        color="#e93f79"
      >
        <ContentTransform fontSize="xl" {...name} />
      </RoughNotation>
      <Text fontSize="md" fontWeight={600} my={3}>
        {company}
      </Text>
      <Box maxW="300px" align="center">
        <ContentTransform {...bio} />
      </Box>
      <Text fontWeight={600} fontSize="md" my={3}>
        {twitter}
      </Text>
    </Flex>
  );
};
Enter fullscreen mode Exit fullscreen mode

cod.js

import { Flex } from "@chakra-ui/react";
import React from "react";
import ContentTransform from "./content-transform";
import Heading from "./heading";
import Container from "./container";

const CoD = ({ cod }) => {
  return (
    <Flex
      mb={3}
      direction="column"
      align="center"
      justify="center"
      p={2}
      m="2em auto"
      boxShadow="-3px 3px #000"
      border="solid 1px"
      w={["300px", "1000px"]}
    >
      <Container>
        <Heading>Code of Conduct</Heading>
      </Container>
      <ContentTransform {...cod} />
    </Flex>
  );
};

export default CoD;
Enter fullscreen mode Exit fullscreen mode

If you now run yarn z your website will look a damn site nicer, but it's lacking some movement. Let's spice things up with some snazzy animations. In the hero folder create 2 new files hero.js and square.js

square.js

import { Box } from "@chakra-ui/react";
import React from "react";

const Square = ({ color, shadowColor, className }) => {
  return (
    <Box
      className={className}
      bgColor={color}
      w="30px"
      h="30px"
      boxShadow={`-3px 3px ${shadowColor}`}
      borderRadius={0}
    />
  );
};

export default Square;
Enter fullscreen mode Exit fullscreen mode

hero.js

import React from "react";
import gsap from "gsap";
import { Flex, Grid, Text } from "@chakra-ui/react";
import Square from "./square";
import Section from "../section";

const Hero = () => {
  // create (9x4) Square elements and attach the Square class
  const topSquaresLeft = Array.from(Array(36)).map((_, index) => {
    return (
      <Square
        key={`${index}-topLeft`}
        className="topLeft"
        color="#000213"
        shadowColor="#fff"
      />
    );
  });
  // create (5x4) Square elements and attach the Square class
  const topSquaresRight = Array.from(Array(20)).map((_, index) => {
    return (
      <Square
        key={`${index}-topRight`}
        className="topRight"
        color="#e93f79"
        shadowColor="#000"
      />
    );
  });
  const bottomSquaresLeft = Array.from(Array(36)).map((_, index) => {
    return (
      <Square
        key={`${index}-bottomLeft`}
        className="bottomLeft"
        color="#000213"
        shadowColor="#fff"
      />
    );
  });
  // create (5x4) Square elements and attach the Square class
  const bottomSquaresRight = Array.from(Array(20)).map((_, index) => {
    return (
      <Square
        key={`${index}-bottomLeft`}
        className="bottomRight"
        color="#e93f79"
        shadowColor="#000"
      />
    );
  });

  React.useEffect(() => {
    gsap.to("body", { visibility: "visible" });

    let TL = gsap.timeline();
    TL.from(".topLeft", {
      y: window.innerHeight * 1,
      x: window.innerWidth * -1,
      duration: 0.5,
      ease: "back.out(1.3)",
      stagger: {
        grid: [9, 4],
        from: "random",
        amount: 1.5,
      },
    });
    TL.from(
      ".topRight",
      {
        y: window.innerHeight * -1,
        x: window.innerWidth * 1,
        duration: 0.6,
        ease: "back.out(1.4)",
        stagger: {
          grid: [5, 4],
          from: "random",
          amount: 1.5,
        },
      },
      "-=1.2"
    );
    TL.from(
      ".title",
      {
        opacity: 0,
        duration: 1,
      },
      "-=1.2"
    );
    TL.from(
      ".bottomLeft",
      {
        y: window.innerHeight * -1,
        x: window.innerWidth * 1,
        duration: 0.7,
        ease: "back.out(1.5)",
        stagger: {
          grid: [9, 4],
          from: "random",
          amount: 1.5,
        },
      },
      "-=1.2"
    );
    TL.from(
      ".bottomRight",
      {
        y: window.innerHeight * 1,
        x: window.innerWidth * -1,
        duration: 0.8,
        ease: "back.out(1.6)",
        stagger: {
          grid: [5, 4],
          from: "random",
          amount: 1.5,
        },
      },
      "-=1.2"
    );
  }, []);

  return (
    <Section fullPage>
      <Flex m="0 auto">
        <Grid
          w="100%"
          templateColumns="repeat(9, 80px)"
          templateRows="repeat(4, 80px)"
          placeItems="center"
          display={["none", "grid"]}
        >
          {topSquaresLeft.map((Square) => Square)}
        </Grid>
        <Grid
          w="100%"
          templateColumns="repeat(5, 80px)"
          templateRows="repeat(4, 80px)"
          placeItems="center"
          display={["none", "grid"]}
        >
          {topSquaresRight.map((Square) => Square)}
        </Grid>
      </Flex>
      <Flex p={5} align="center" justify="center" w="100%">
        <Text
          textTransform="uppercase"
          fontFamily="heading"
          fontSize="6xl"
          fontWeight={700}
          color="brand.offWhite"
          className="title"
          letterSpacing={[2, 5]}
          textShadow={[
            null,
            "-3px -3px 0px #fff, 3px -3px 0px #fff, -3px 3px 0px #fff, 3px 3px 0px #fff, 4px 4px 0px #000, 5px 5px 0px #000, 6px 6px 0px #000, 7px 7px 0px #000, 8px 8px 0px #000, 9px 9px 0px #000",
          ]}
        >
          The conf vault
        </Text>
      </Flex>
      <Flex m="0 auto">
        <Grid
          w="100%"
          templateColumns="repeat(9, 80px)"
          templateRows="repeat(4, 80px)"
          placeItems="center"
          display={["none", "grid"]}
        >
          {bottomSquaresLeft.map((Square) => Square)}
        </Grid>
        <Grid
          w="100%"
          templateColumns="repeat(5, 80px)"
          templateRows="repeat(4, 80px)"
          placeItems="center"
          display={["none", "grid"]}
        >
          {bottomSquaresRight.map((Square) => Square)}
        </Grid>
      </Flex>
    </Section>
  );
};

export default Hero;
Enter fullscreen mode Exit fullscreen mode

That's quite alot of information to take in, let's step through it.

  • We create an array of 36 elements (a grid of 9x4) and map over the indexes returning the square component. It's named topSquaresLeft, we then do the same for each corner or the page.
  • In the useEffect hook we set the body visibility to visible. We then create a gsap timeline. (The inner workings of gsap will not be covered here, their docs are very good and would be a great place to start. I've also written some notes on gettings started with gsap, which you can find at richardhaines.dev/notes-on-gsap) With the timeline we initiate a staggered animation of all the boxes from each corner of the page, during this we animate the opacity of the title so that it gradually reveals itself during the boxes animations.
  • We setup 4 grids and map over each of our arrays of squares.

Finally update the index.js file, adding the layout, hero and state components.

import React from "react";

import { useQuery, gql } from "@apollo/client";
import Hero from "../components/hero/hero";
import Layout from "./../components/layout";
import Event from "../components/event/event";
import Loading from "../components/state/loading";
import Error from "../components/state/error";

export default function Index() {
  const { loading, error, data } = useQuery(GET_ROOT_PATHS);

  if (loading) {
    return <Loading />;
  }
  if (error) {
    return <Error />;
  }

  const conferencePaths = data.catalogue.children[0].children.map(
    (node) => node.path
  );

  return (
    <Layout>
      <Hero />
      {conferencePaths.map((path, index) => (
        <Event key={index} path={path} />
      ))}
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode

Thanks for taking the time to read along, if you have any questions feel free to shoot me a message on Twitter @studio_hungry

Discussion (0)

pic
Editor guide