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 = ( +
+ {image?.description} +
+ ); + + 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 ( +
+ {block.image?.description} +
+ ); +} 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 ? ( + {asset.description} + ) : 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'}