From a1d65a54c10859173979c0381749c83267f3c903 Mon Sep 17 00:00:00 2001 From: Chloe Date: Thu, 23 May 2024 14:17:55 +0700 Subject: [PATCH] feat: implement text/image-with-text/icon-with-text content block Signed-off-by: Chloe --- app/[page]/layout.tsx | 25 ++++-- app/[page]/page.tsx | 49 +++++++++--- components/hero-icon.tsx | 17 ++++ components/layout/footer.tsx | 19 ++++- components/page/icon-with-text-block.tsx | 94 +++++++++++++++++++++++ components/page/image-display.tsx | 17 ++++ components/page/image-with-text-block.tsx | 37 +++++++++ components/page/rich-text-display.tsx | 37 +++++++++ components/page/text-block.tsx | 23 ++++++ lib/shopify/index.ts | 42 +++++++++- lib/shopify/queries/node.ts | 33 ++++++++ lib/shopify/queries/page.ts | 7 +- lib/shopify/types.ts | 37 ++++++++- package.json | 2 + pnpm-lock.yaml | 16 ++++ 15 files changed, 431 insertions(+), 24 deletions(-) create mode 100644 components/hero-icon.tsx create mode 100644 components/page/icon-with-text-block.tsx create mode 100644 components/page/image-display.tsx create mode 100644 components/page/image-with-text-block.tsx create mode 100644 components/page/rich-text-display.tsx create mode 100644 components/page/text-block.tsx create mode 100644 lib/shopify/queries/node.ts diff --git a/app/[page]/layout.tsx b/app/[page]/layout.tsx index 453253dca..bc86c91cd 100644 --- a/app/[page]/layout.tsx +++ b/app/[page]/layout.tsx @@ -1,15 +1,26 @@ import Footer from 'components/layout/footer'; import { Suspense } from 'react'; +const Placeholder = () => { + return ( +
+
+
+
+ ); +}; + export default function Layout({ children }: { children: React.ReactNode }) { return ( - -
-
- {children} -
+ <> +
+ }> +
{children}
+
-
- + +
+ + ); } diff --git a/app/[page]/page.tsx b/app/[page]/page.tsx index c21525502..c173ef197 100644 --- a/app/[page]/page.tsx +++ b/app/[page]/page.tsx @@ -1,8 +1,12 @@ import type { Metadata } from 'next'; -import Prose from 'components/prose'; -import { getPage } from 'lib/shopify'; +import IconWithTextBlock, { IconBlockPlaceholder } from 'components/page/icon-with-text-block'; +import ImageWithTextBlock from 'components/page/image-with-text-block'; +import TextBlock from 'components/page/text-block'; +import { getPage, getPageMetaObjects } from 'lib/shopify'; +import { PageContent, PageMetafieldKey } from 'lib/shopify/types'; import { notFound } from 'next/navigation'; +import { Suspense } from 'react'; export const runtime = 'edge'; @@ -26,22 +30,45 @@ export async function generateMetadata({ }; } +// eslint-disable-next-line no-unused-vars +const contentMap: Record JSX.Element> = { + page_icon_section: (content) => ( + }> + + + ), + page_image_content: (content) => , + page_section: (content) => +}; + export default async function Page({ params }: { params: { page: string } }) { const page = await getPage(params.page); if (!page) return notFound(); + const pageContents = ( + await Promise.allSettled(page.metafields.map((metafield) => getPageMetaObjects(metafield))) + ) + .filter((result) => result.status === 'fulfilled') + .map((result) => (result as PromiseFulfilledResult).value) + .filter(Boolean) as PageContent[]; + return ( <> -

{page.title}

- -

- {`This document was last updated on ${new Intl.DateTimeFormat(undefined, { - year: 'numeric', - month: 'long', - day: 'numeric' - }).format(new Date(page.updatedAt))}.`} -

+
+

+ {page.title} +

+
+
+
+
+ {pageContents.map((content) => ( +
{contentMap[content.key](content)}
+ ))} +
+
+
); } diff --git a/components/hero-icon.tsx b/components/hero-icon.tsx new file mode 100644 index 000000000..e23895bb6 --- /dev/null +++ b/components/hero-icon.tsx @@ -0,0 +1,17 @@ +import * as HeroIcons from '@heroicons/react/24/outline'; +import startcase from 'lodash.startcase'; + +type IconName = keyof typeof HeroIcons; +interface IconProps { + icon: string; + className?: string; +} + +const DynamicHeroIcon = ({ icon, className }: IconProps) => { + const _icon = startcase(icon).replace(/\s/g, ''); + const SingleIcon = HeroIcons[`${_icon}Icon` as IconName]; + + return SingleIcon ? : null; +}; + +export default DynamicHeroIcon; diff --git a/components/layout/footer.tsx b/components/layout/footer.tsx index 8211cb00e..ab88f767f 100644 --- a/components/layout/footer.tsx +++ b/components/layout/footer.tsx @@ -53,13 +53,26 @@ export default async function Footer() { {copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved.

- visa - mastercard + visa + mastercard american-express
diff --git a/components/page/icon-with-text-block.tsx b/components/page/icon-with-text-block.tsx new file mode 100644 index 000000000..bd200595d --- /dev/null +++ b/components/page/icon-with-text-block.tsx @@ -0,0 +1,94 @@ +import Grid from 'components/grid'; +import DynamicHeroIcon from 'components/hero-icon'; +import { getMetaobjects, getMetaobjectsByIds } from 'lib/shopify'; +import { PageContent, ScreenSize } from 'lib/shopify/types'; + +export const IconBlockPlaceholder = () => { + return ( +
+
+
+
+
+ ); +}; + +const IconWithTextBlock = async ({ content }: { content: PageContent }) => { + // for icon with text content, we only need the first metaobject as the array always contains only one element due to the metafield definition set up on Shopify + const metaobject = content.metaobjects[0]; + + if (!metaobject) return null; + + const [contentBlocks, layouts, screenSizes] = await Promise.all([ + getMetaobjectsByIds(metaobject.content ? JSON.parse(metaobject.content) : []), + getMetaobjectsByIds(metaobject.layouts ? JSON.parse(metaobject.layouts) : []), + getMetaobjects('screen_sizes') + ]); + + const availableLayouts = layouts.reduce( + (acc, layout) => { + const screenSize = screenSizes.find((screen) => screen.id === layout.screen_size); + if (screenSize?.size) { + acc[screenSize.size.toLowerCase() as ScreenSize] = Number(layout.number_of_columns); + } + + return acc; + }, + {} as Record + ); + + let classnames = {} as { [key: string]: boolean }; + + if (availableLayouts.small) { + classnames = { + ...classnames, + 'sm:grid-cols-1': availableLayouts.small === 1, + 'sm:grid-cols-2': availableLayouts.small === 2, + 'sm:grid-cols-3': availableLayouts.small === 3, + 'sm:grid-cols-4': availableLayouts.small === 4 + }; + } + + if (availableLayouts.medium) { + classnames = { + ...classnames, + 'md:grid-cols-1': availableLayouts.medium === 1, + 'md:grid-cols-2': availableLayouts.medium === 2, + 'md:grid-cols-3': availableLayouts.medium === 3, + 'md:grid-cols-4': availableLayouts.medium === 4 + }; + } + + if (availableLayouts.large) { + classnames = { + ...classnames, + 'lg:grid-cols-1': availableLayouts.large === 1, + 'lg:grid-cols-2': availableLayouts.large === 2, + 'lg:grid-cols-3': availableLayouts.large === 3, + 'lg:grid-cols-4': availableLayouts.large === 4 + }; + } + + const validClassnames = Object.keys(classnames) + .filter((key) => classnames[key]) + .join(' '); + + return ( +
+

{metaobject.title}

+ + {contentBlocks.map((block) => ( + + {block.icon_name && ( + + )} +
{block.title}
+

{block.content}

+
+ ))} +
+
+ ); +}; + +export default IconWithTextBlock; diff --git a/components/page/image-display.tsx b/components/page/image-display.tsx new file mode 100644 index 000000000..b1b19cbdc --- /dev/null +++ b/components/page/image-display.tsx @@ -0,0 +1,17 @@ +import { getImage } from 'lib/shopify'; +import Image from 'next/image'; + +const ImageDisplay = async ({ fileId, title }: { fileId: string; title: string }) => { + const image = await getImage(fileId); + return ( + {image.altText + ); +}; + +export default ImageDisplay; diff --git a/components/page/image-with-text-block.tsx b/components/page/image-with-text-block.tsx new file mode 100644 index 000000000..c9ae5422f --- /dev/null +++ b/components/page/image-with-text-block.tsx @@ -0,0 +1,37 @@ +import { PageContent } from 'lib/shopify/types'; +import { Suspense } from 'react'; +import ImageDisplay from './image-display'; +import RichTextDisplay from './rich-text-display'; + +const ImageWithTextBlock = ({ content }: { content: PageContent }) => { + if (!content.metaobjects.length) return null; + + return ( +
+ {content.metaobjects.map((metaobject) => { + const contentBlocks = JSON.parse(metaobject.description || '{}'); + + return ( +
+

{metaobject.title}

+
+
+ + + +
+
+ +
+
+
+ ); + })} +
+ ); +}; + +export default ImageWithTextBlock; diff --git a/components/page/rich-text-display.tsx b/components/page/rich-text-display.tsx new file mode 100644 index 000000000..56e9cea6f --- /dev/null +++ b/components/page/rich-text-display.tsx @@ -0,0 +1,37 @@ +type Content = + | { type: 'paragraph'; children: Array<{ type: 'text'; value: string; bold?: boolean }> } + | { + type: 'text'; + value: string; + bold?: boolean; + }; + +const RichTextBlock = ({ block }: { block: Content }) => { + if (block.type === 'text') { + return block.bold ? ( + {block.value} + ) : ( + {block.value} + ); + } + + return ( +

+ {block.children.map((child, index) => ( + + ))} +

+ ); +}; + +const RichTextDisplay = ({ contentBlocks }: { contentBlocks: Content[] }) => { + return ( +
+ {contentBlocks.map((block, index) => ( + + ))} +
+ ); +}; + +export default RichTextDisplay; diff --git a/components/page/text-block.tsx b/components/page/text-block.tsx new file mode 100644 index 000000000..ae5a15a78 --- /dev/null +++ b/components/page/text-block.tsx @@ -0,0 +1,23 @@ +import { PageContent } from 'lib/shopify/types'; +import RichTextDisplay from './rich-text-display'; + +const TextBlock = ({ content }: { content: PageContent }) => { + if (!content.metaobjects.length) return null; + + return ( +
+ {content.metaobjects.map((metaobject) => { + const contentBlocks = JSON.parse(metaobject.content || '{}'); + + return ( +
+

{metaobject.title}

+ +
+ ); + })} +
+ ); +}; + +export default TextBlock; diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index 2d7c71dda..1325915de 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -29,6 +29,7 @@ import { } from './queries/collection'; import { getMenuQuery } from './queries/menu'; import { getMetaobjectsQuery } from './queries/metaobject'; +import { getImageQuery, getMetaobjectsByIdsQuery } from './queries/node'; import { getPageQuery, getPagesQuery } from './queries/page'; import { getProductQuery, @@ -44,8 +45,10 @@ import { Menu, Metaobject, Money, + PAGE_TYPES, Page, PageInfo, + PageMetafield, Product, ProductVariant, ShopifyAddToCartOperation, @@ -57,8 +60,10 @@ import { ShopifyCollectionsOperation, ShopifyCreateCartOperation, ShopifyFilter, + ShopifyImageOperation, ShopifyMenuOperation, ShopifyMetaobject, + ShopifyMetaobjectOperation, ShopifyMetaobjectsOperation, ShopifyPageOperation, ShopifyPagesOperation, @@ -490,10 +495,36 @@ export async function getMetaobjects(type: string) { return reshapeMetaobjects(removeEdgesAndNodes(res.body.data.metaobjects)); } +export async function getMetaobjectsByIds(ids: string[]) { + if (!ids.length) return []; + + const res = await shopifyFetch({ + query: getMetaobjectsByIdsQuery, + variables: { ids } + }); + + return reshapeMetaobjects(res.body.data.nodes); +} + +export async function getPageMetaObjects(metafield: PageMetafield) { + let metaobjectIds = parseMetaFieldValue(metafield) || metafield.value; + + if (!metaobjectIds) { + return null; + } + + metaobjectIds = (Array.isArray(metaobjectIds) ? metaobjectIds : [metaobjectIds]) as string[]; + + const metaobjects = await getMetaobjectsByIds(metaobjectIds); + + return { metaobjects, id: metafield.id, key: metafield.key }; +} + export async function getPage(handle: string): Promise { + const metafieldIdentifiers = PAGE_TYPES.map((key) => ({ key, namespace: 'custom' })); const res = await shopifyFetch({ query: getPageQuery, - variables: { handle } + variables: { handle, metafieldIdentifiers } }); return res.body.data.pageByHandle; @@ -604,3 +635,12 @@ export async function revalidate(req: NextRequest): Promise { return NextResponse.json({ status: 200, revalidated: true, now: Date.now() }); } + +export const getImage = async (id: string): Promise => { + const res = await shopifyFetch({ + query: getImageQuery, + variables: { id } + }); + + return res.body.data.node.image; +}; diff --git a/lib/shopify/queries/node.ts b/lib/shopify/queries/node.ts new file mode 100644 index 000000000..9adaa5556 --- /dev/null +++ b/lib/shopify/queries/node.ts @@ -0,0 +1,33 @@ +export const getImageQuery = /* GraphQL */ ` + query getImage($id: ID!) { + node(id: $id) { + ... on MediaImage { + image { + altText + width + height + url + } + } + } + } +`; + +export const getMetaobjectsByIdsQuery = /* GraphQL */ ` + query getMetaobjectsByIds($ids: [ID!]!) { + nodes(ids: $ids) { + ... on Metaobject { + id + fields { + reference { + ... on Metaobject { + id + } + } + key + value + } + } + } + } +`; diff --git a/lib/shopify/queries/page.ts b/lib/shopify/queries/page.ts index ac6f6f986..179913095 100644 --- a/lib/shopify/queries/page.ts +++ b/lib/shopify/queries/page.ts @@ -19,9 +19,14 @@ const pageFragment = /* GraphQL */ ` `; export const getPageQuery = /* GraphQL */ ` - query getPage($handle: String!) { + query getPage($handle: String!, $metafieldIdentifiers: [HasMetafieldsIdentifier!]!) { pageByHandle(handle: $handle) { ...page + metafields(identifiers: $metafieldIdentifiers) { + value + key + id + } } } ${pageFragment} diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index 3ef0cbe57..2bb02dda5 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -51,6 +51,15 @@ export type Money = { currencyCode: string; }; +export type PageMetafield = { + id: string; + key: PageMetafieldKey; + value: string; +}; + +export const PAGE_TYPES = ['page_icon_section', 'page_section', 'page_image_content'] as const; +export type PageMetafieldKey = (typeof PAGE_TYPES)[number]; + export type Page = { id: string; title: string; @@ -60,6 +69,12 @@ export type Page = { seo?: SEO; createdAt: string; updatedAt: string; + metafields: PageMetafield[]; +}; + +export type MetafieldIdentifier = { + key: string; + namespace: string; }; export type ShopifyMetaobject = { @@ -282,7 +297,12 @@ export type ShopifyMenuOperation = { export type ShopifyPageOperation = { data: { pageByHandle: Page }; - variables: { handle: string }; + variables: { handle: string; metafieldIdentifiers: MetafieldIdentifier[] }; +}; + +export type ShopifyImageOperation = { + data: { node: { image: Image } }; + variables: { id: string }; }; export type ShopifyMetaobjectsOperation = { @@ -296,6 +316,11 @@ export type ShopifyPagesOperation = { }; }; +export type ShopifyMetaobjectOperation = { + data: { nodes: ShopifyMetaobject[] }; + variables: { ids: string[] }; +}; + export type ShopifyProductOperation = { data: { product: ShopifyProduct }; variables: { @@ -363,3 +388,13 @@ export type Filter = { value: unknown; }[]; }; + +export type PageContent = { + id: string; + key: PageMetafieldKey; + metaobjects: Metaobject[]; +}; + +export const SCREEN_SIZES = ['small', 'medium', 'large', 'extra_large'] as const; + +export type ScreenSize = (typeof SCREEN_SIZES)[number]; diff --git a/package.json b/package.json index 05ac76aa3..5c20c0cee 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "clsx": "^2.1.0", "geist": "^1.3.0", "lodash.get": "^4.4.2", + "lodash.startcase": "^4.4.0", "next": "14.1.4", "react": "18.2.0", "react-dom": "18.2.0", @@ -40,6 +41,7 @@ "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.11", "@types/lodash.get": "^4.4.9", + "@types/lodash.startcase": "^4.4.9", "@types/node": "20.11.30", "@types/react": "18.2.72", "@types/react-dom": "18.2.22", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f78bba5b4..0962faa59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ dependencies: lodash.get: specifier: ^4.4.2 version: 4.4.2 + lodash.startcase: + specifier: ^4.4.0 + version: 4.4.0 next: specifier: 14.1.4 version: 14.1.4(react-dom@18.2.0)(react@18.2.0) @@ -55,6 +58,9 @@ devDependencies: '@types/lodash.get': specifier: ^4.4.9 version: 4.4.9 + '@types/lodash.startcase': + specifier: ^4.4.9 + version: 4.4.9 '@types/node': specifier: 20.11.30 version: 20.11.30 @@ -753,6 +759,12 @@ packages: '@types/lodash': 4.17.1 dev: true + /@types/lodash.startcase@4.4.9: + resolution: {integrity: sha512-C0M4DlN1pnn2vEEhLHkTHxiRZ+3GlTegpoAEHHGXnuJkSOXyJMHGiSc+SLRzBlFZWHsBkixe6FqvEAEU04g14g==} + dependencies: + '@types/lodash': 4.17.1 + dev: true + /@types/lodash@4.17.1: resolution: {integrity: sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q==} dev: true @@ -2632,6 +2644,10 @@ packages: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + dev: false + /log-update@6.0.0: resolution: {integrity: sha512-niTvB4gqvtof056rRIrTZvjNYE4rCUzO6X/X+kYjd7WFxXeJ0NwEFnRxX6ehkvv3jTwrXnNdtAak5XYZuIyPFw==} engines: {node: '>=18'}