feat: implement text/image-with-text/icon-with-text content block

Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
Chloe 2024-05-23 14:17:55 +07:00
parent 4684d54ac3
commit a1d65a54c1
No known key found for this signature in database
GPG Key ID: CFD53CE570D42DF5
15 changed files with 431 additions and 24 deletions

View File

@ -1,15 +1,26 @@
import Footer from 'components/layout/footer';
import { Suspense } from 'react';
const Placeholder = () => {
return (
<div className="mx-auto mb-2 w-full max-w-7xl animate-pulse py-6">
<div className="h-10 w-1/2 rounded bg-gray-200" />
<div className="mt-6 h-96 w-full rounded bg-gray-200" />
</div>
);
};
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<div className="w-full">
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">
<Suspense>{children}</Suspense>
</div>
<>
<div className="min-h-[600px] w-full">
<Suspense fallback={<Placeholder />}>
<div className="py-6">{children}</div>
</Suspense>
</div>
<Footer />
</Suspense>
<Suspense>
<Footer />
</Suspense>
</>
);
}

View File

@ -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<PageMetafieldKey, (content: PageContent) => JSX.Element> = {
page_icon_section: (content) => (
<Suspense fallback={<IconBlockPlaceholder />}>
<IconWithTextBlock content={content} />
</Suspense>
),
page_image_content: (content) => <ImageWithTextBlock content={content} />,
page_section: (content) => <TextBlock content={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<PageContent | null>).value)
.filter(Boolean) as PageContent[];
return (
<>
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
<Prose className="mb-8" html={page.body as string} />
<p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(page.updatedAt))}.`}
</p>
<div className="mx-auto mb-2 max-w-7xl px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
{page.title}
</h1>
</div>
<main>
<div className="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
<div className="flex flex-col space-y-16">
{pageContents.map((content) => (
<div key={content.id}>{contentMap[content.key](content)}</div>
))}
</div>
</div>
</main>
</>
);
}

17
components/hero-icon.tsx Normal file
View File

@ -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 ? <SingleIcon className={className} /> : null;
};
export default DynamicHeroIcon;

View File

@ -53,13 +53,26 @@ export default async function Footer() {
{copyrightName.length && !copyrightName.endsWith('.') ? '.' : ''} All rights reserved.
</p>
<div className="ml-0 flex flex-row items-center gap-2 md:ml-auto">
<Image alt="visa" src="/icons/visa.png" width={30} height={20} />
<Image alt="mastercard" src="/icons/mastercard.png" width={30} height={20} />
<Image
alt="visa"
src="/icons/visa.png"
width={30}
height={30}
className="h-auto w-[30px]"
/>
<Image
alt="mastercard"
src="/icons/mastercard.png"
width={30}
height={30}
className="h-auto w-[30px]"
/>
<Image
alt="american-express"
src="/icons/american-express.png"
width={30}
height={20}
height={30}
className="h-auto w-[30px]"
/>
</div>
</div>

View File

@ -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 (
<div className="flex animate-pulse flex-col gap-5 px-4 md:px-0">
<div className="h-10 w-1/2 rounded bg-gray-200" />
<div className="h-40 w-full rounded bg-gray-200" />
<div className="h-40 w-full rounded bg-gray-200" />
</div>
);
};
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<ScreenSize, number>
);
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 (
<div className="flex flex-col gap-5 px-4 md:px-0">
<h3 className="text-xl font-semibold leading-6 text-gray-900">{metaobject.title}</h3>
<Grid className={validClassnames}>
{contentBlocks.map((block) => (
<Grid.Item key={block.id} className="flex flex-col gap-2">
{block.icon_name && (
<DynamicHeroIcon icon={block.icon_name} className="w-16 text-secondary" />
)}
<div className="text-lg font-medium">{block.title}</div>
<p className="text-base text-gray-800">{block.content}</p>
</Grid.Item>
))}
</Grid>
</div>
);
};
export default IconWithTextBlock;

View File

@ -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
src={image.url}
alt={image.altText || `Display Image for ${title} section`}
width={image.width}
height={image.height}
className="h-full w-full object-contain"
/>
);
};
export default ImageDisplay;

View File

@ -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 (
<div className="flex flex-col gap-10">
{content.metaobjects.map((metaobject) => {
const contentBlocks = JSON.parse(metaobject.description || '{}');
return (
<div className="flex flex-col gap-6 px-4 md:px-0" key={metaobject.id}>
<h3 className="text-xl font-semibold leading-6 text-gray-900">{metaobject.title}</h3>
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
<div className="relative col-span-1">
<Suspense>
<ImageDisplay
title={metaobject.title as string}
fileId={metaobject.file as string}
/>
</Suspense>
</div>
<div className="col-span-2">
<RichTextDisplay contentBlocks={contentBlocks.children} />
</div>
</div>
</div>
);
})}
</div>
);
};
export default ImageWithTextBlock;

View File

@ -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 ? (
<strong className="font-semibold">{block.value}</strong>
) : (
<span>{block.value}</span>
);
}
return (
<p className="text-gray-800">
{block.children.map((child, index) => (
<RichTextBlock key={index} block={child} />
))}
</p>
);
};
const RichTextDisplay = ({ contentBlocks }: { contentBlocks: Content[] }) => {
return (
<div className="flex w-full flex-col gap-2">
{contentBlocks.map((block, index) => (
<RichTextBlock key={index} block={block} />
))}
</div>
);
};
export default RichTextDisplay;

View File

@ -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 (
<div className="flex flex-col gap-8">
{content.metaobjects.map((metaobject) => {
const contentBlocks = JSON.parse(metaobject.content || '{}');
return (
<div className="flex flex-col gap-5 px-4 md:px-0" key={metaobject.id}>
<h3 className="text-xl font-semibold leading-6 text-gray-900">{metaobject.title}</h3>
<RichTextDisplay contentBlocks={contentBlocks.children} />
</div>
);
})}
</div>
);
};
export default TextBlock;

View File

@ -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<ShopifyMetaobjectOperation>({
query: getMetaobjectsByIdsQuery,
variables: { ids }
});
return reshapeMetaobjects(res.body.data.nodes);
}
export async function getPageMetaObjects(metafield: PageMetafield) {
let metaobjectIds = parseMetaFieldValue<string | string[]>(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<Page> {
const metafieldIdentifiers = PAGE_TYPES.map((key) => ({ key, namespace: 'custom' }));
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
variables: { handle }
variables: { handle, metafieldIdentifiers }
});
return res.body.data.pageByHandle;
@ -604,3 +635,12 @@ export async function revalidate(req: NextRequest): Promise<NextResponse> {
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}
export const getImage = async (id: string): Promise<Image> => {
const res = await shopifyFetch<ShopifyImageOperation>({
query: getImageQuery,
variables: { id }
});
return res.body.data.node.image;
};

View File

@ -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
}
}
}
}
`;

View File

@ -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}

View File

@ -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];

View File

@ -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",

16
pnpm-lock.yaml generated
View File

@ -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'}