diff --git a/apps/core/components/cms/Banner.jsx b/apps/core/components/cms/Banner.jsx
new file mode 100644
index 00000000..f9db7c39
--- /dev/null
+++ b/apps/core/components/cms/Banner.jsx
@@ -0,0 +1,25 @@
+import RichText from './RichText';
+
+export default function Banner({ className, block }) {
+ const { heading, imagePosition, backgroundColor, style, content, image } = block;
+
+ const bgClass = style == 'light' ? `bg-${backgroundColor}-200` : `bg-${backgroundColor}-800`;
+ const textColorClass = style == 'light' ? 'text-slate-800' : 'text-slate-200';
+
+ const bannerImg = (
+
+
+
+ );
+
+ return (
+
+ {imagePosition === 'left' && bannerImg}
+
+
{heading}
+
+
+ {imagePosition === 'right' && bannerImg}
+
+ );
+}
diff --git a/apps/core/components/cms/CmsContent.jsx b/apps/core/components/cms/CmsContent.jsx
new file mode 100644
index 00000000..38faea70
--- /dev/null
+++ b/apps/core/components/cms/CmsContent.jsx
@@ -0,0 +1,11 @@
+import ContentBlock from 'components/cms/ContentBlock';
+
+export default function CmsContent({ blocks, className = '' }) {
+ return (
+
+ {blocks.map((block) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/core/components/cms/ContentBlock.jsx b/apps/core/components/cms/ContentBlock.jsx
new file mode 100644
index 00000000..bfbf6789
--- /dev/null
+++ b/apps/core/components/cms/ContentBlock.jsx
@@ -0,0 +1,30 @@
+import Banner from 'components/cms/Banner';
+import ImageBlock from 'components/cms/ImageBlock';
+import RichText from 'components/cms/RichText';
+import SimpleText from 'components/cms/SimpleText';
+
+const BLOCK_TYPE_BANNER = 'BlockBanner';
+const BLOCK_TYPE_RICHTEXT = 'BlockRichText';
+const BLOCK_TYPE_SIMPLETEXT = 'BlockSimpleText';
+const BLOCK_TYPE_IMG = 'BlockImage';
+
+export default function ContentBlock({ block }) {
+ switch (block['__typename']) {
+ case BLOCK_TYPE_BANNER:
+ return ;
+
+ case BLOCK_TYPE_RICHTEXT:
+ return (
+
+ );
+
+ case BLOCK_TYPE_SIMPLETEXT:
+ return ;
+
+ case BLOCK_TYPE_IMG:
+ return ;
+
+ default:
+ return null;
+ }
+}
diff --git a/apps/core/components/cms/ImageBlock.jsx b/apps/core/components/cms/ImageBlock.jsx
new file mode 100644
index 00000000..445c890c
--- /dev/null
+++ b/apps/core/components/cms/ImageBlock.jsx
@@ -0,0 +1,33 @@
+export default function ImageBlock({ className, block }) {
+ let sizeClass, imgSize;
+ switch (block.size) {
+ case 'full':
+ sizeClass = 'col-span-6';
+ imgSize = 1000;
+ break;
+
+ case 'two-thirds':
+ sizeClass = 'col-span-4';
+ imgSize = 700;
+ break;
+
+ case 'half':
+ sizeClass = 'col-span-3';
+ imgSize = 500;
+ break;
+
+ case 'third':
+ sizeClass = 'col-span-2';
+ imgSize = 400;
+ break;
+
+ default:
+ sizeClass = null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/core/components/cms/RichText.jsx b/apps/core/components/cms/RichText.jsx
new file mode 100644
index 00000000..09f6af09
--- /dev/null
+++ b/apps/core/components/cms/RichText.jsx
@@ -0,0 +1,26 @@
+import { documentToReactComponents } from '@contentful/rich-text-react-renderer';
+import { BLOCKS } from '@contentful/rich-text-types';
+
+function RichTextAsset({ id, content }) {
+ const assetLinks = content?.links?.assets?.block ?? [];
+
+ const asset = assetLinks.find((asset) => asset.sys.id === id);
+
+ return asset?.url ? (
+
+ ) : null;
+}
+
+export default function RichText({ content, className }) {
+ return (
+
+ {documentToReactComponents(content.json, {
+ renderNode: {
+ [BLOCKS.EMBEDDED_ASSET]: (node) => (
+
+ )
+ }
+ })}
+
+ );
+}
diff --git a/apps/core/components/cms/SimpleText.jsx b/apps/core/components/cms/SimpleText.jsx
new file mode 100644
index 00000000..d2655db9
--- /dev/null
+++ b/apps/core/components/cms/SimpleText.jsx
@@ -0,0 +1,27 @@
+import RichText from './RichText';
+
+export default function SimpleText({ className, block }) {
+ let sizeClass;
+ switch (block.size) {
+ case 'full':
+ sizeClass = 'col-span-6';
+ break;
+
+ case 'two-thirds':
+ sizeClass = 'col-span-4';
+ break;
+
+ case 'half':
+ sizeClass = 'col-span-3';
+ break;
+
+ case 'third':
+ sizeClass = 'col-span-2';
+ break;
+
+ default:
+ sizeClass = null;
+ }
+
+ return ;
+}
diff --git a/apps/core/lib/contentful/api.js b/apps/core/lib/contentful/api.js
new file mode 100644
index 00000000..5b48ebf9
--- /dev/null
+++ b/apps/core/lib/contentful/api.js
@@ -0,0 +1,112 @@
+const QUERY_CONTENT = `
+query ContentCollection(
+ $type: String
+ $slug: String
+) {
+ categoryContentCollection(
+ where: {slug: $slug, type: $type},
+ limit: 1
+ ) {
+ items {
+ contentCollection(limit: 10) {
+ items {
+ __typename
+ ... on BlockBanner {
+ sys {
+ id
+ }
+ heading
+ imagePosition
+ backgroundColor
+ style
+ content {
+ json
+ }
+ image {
+ title
+ description
+ url
+ }
+ }
+ ... on BlockRichText {
+ sys {
+ id
+ }
+ content {
+ json
+ links {
+ assets {
+ block {
+ title
+ description
+ url
+ sys {
+ id
+ }
+ }
+ }
+ }
+ }
+ }
+ ... on BlockSimpleText {
+ sys {
+ id
+ }
+ size
+ content {
+ json
+ }
+ }
+ ... on BlockImage {
+ sys {
+ id
+ }
+ size
+ image {
+ title
+ description
+ url
+ }
+ }
+ }
+ }
+ }
+ }
+}
+`;
+
+const extractCategoryContent = (responseData) => {
+ return responseData?.data?.categoryContentCollection?.items?.[0]?.contentCollection?.items;
+};
+
+async function fetchGraphQL(query, variables, cache = 'force-cache') {
+ const fetchOpts = {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.CONTENTFUL_ACCESS_TOKEN}`
+ },
+ body: JSON.stringify({
+ ...(query && { query }),
+ ...(variables && { variables })
+ })
+ };
+ if (cache !== 'no-store') {
+ fetchOpts.next = {
+ revalidate: parseInt(process.env.FETCH_REVALIDATE_TIME)
+ };
+ } else {
+ fetchOpts.cache = cache;
+ }
+
+ return fetch(
+ `https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`,
+ fetchOpts
+ ).then((response) => response.json());
+}
+
+export async function getContentBlocks(type, slug) {
+ const blocks = await fetchGraphQL(QUERY_CONTENT, { type, slug });
+ return extractCategoryContent(blocks);
+}
diff --git a/apps/core/package.json b/apps/core/package.json
index 557223d5..829d3294 100644
--- a/apps/core/package.json
+++ b/apps/core/package.json
@@ -14,6 +14,8 @@
"dependencies": {
"@bigcommerce/catalyst-client": "workspace:^",
"@bigcommerce/reactant": "workspace:^",
+ "@contentful/rich-text-react-renderer": "^15.19.0",
+ "@contentful/rich-text-types": "^16.3.0",
"@icons-pack/react-simple-icons": "^9.1.0",
"@vercel/analytics": "^1.1.1",
"clsx": "^2.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dca6a5ce..c9373530 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,12 @@ importers:
'@bigcommerce/reactant':
specifier: workspace:^
version: link:../../packages/reactant
+ '@contentful/rich-text-react-renderer':
+ specifier: ^15.19.0
+ version: 15.19.0(react-dom@18.2.0)(react@18.2.0)
+ '@contentful/rich-text-types':
+ specifier: ^16.3.0
+ version: 16.3.0
'@icons-pack/react-simple-icons':
specifier: ^9.1.0
version: 9.1.0(react@18.2.0)
@@ -1809,6 +1815,23 @@ packages:
requiresBuild: true
optional: true
+ /@contentful/rich-text-react-renderer@15.19.0(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-2lFDmoWocUXNxk+yvHNSUIgdhm7FCyE57KjYxuTnoC8R2nPVY+mC0I2c0aTOcP4CX/2QqZe/FAoospjU6nk1uw==}
+ engines: {node: '>=6.0.0'}
+ peerDependencies:
+ react: ^16.8.6 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.8.6 || ^17.0.0 || ^18.0.0
+ dependencies:
+ '@contentful/rich-text-types': 16.3.0
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
+ /@contentful/rich-text-types@16.3.0:
+ resolution: {integrity: sha512-OfQmAu5bxE0CgQA3WlUleVej+ifFG/iXmB2DmUl4EyWyFue1aiIvfjxQhcDRSH4n1jUNMJ6L1wInZL8uV5m3TQ==}
+ engines: {node: '>=6.0.0'}
+ dev: false
+
/@discoveryjs/json-ext@0.5.7:
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}