feat: homepage and integrate with shopify page

Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
Chloe 2024-05-27 16:48:22 +07:00
parent e0da620ac9
commit 8f0801689c
No known key found for this signature in database
GPG Key ID: CFD53CE570D42DF5
26 changed files with 369 additions and 107 deletions

View File

@ -1,33 +1,36 @@
import { Carousel } from 'components/carousel'; import Hero from 'components/hero';
import YMMFilters, { YMMFiltersPlaceholder } from 'components/filters'; import HomePageContent from 'components/home-page-content';
import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer'; import Footer from 'components/layout/footer';
import { getPage } from 'lib/shopify';
import { Metadata } from 'next';
import { Suspense } from 'react'; import { Suspense } from 'react';
export const runtime = 'edge'; export const runtime = 'edge';
export const metadata = { export async function generateMetadata(): Promise<Metadata> {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.', const page = await getPage('home-page');
return {
title: page?.seo?.title || page?.title,
description: page?.seo?.description || page?.bodySummary,
openGraph: { openGraph: {
type: 'website' type: 'website'
} }
}; };
}
export default async function HomePage() { export default async function HomePage() {
return ( return (
<> <>
<section className="mx-auto max-w-screen-2xl px-4 pb-4"> <Hero />
<Suspense fallback={<YMMFiltersPlaceholder />}> <div className="mt-3">
<YMMFilters />
</Suspense>
</section>
<ThreeItemGrid />
<Suspense> <Suspense>
<Carousel /> <HomePageContent />
</Suspense>
</div>
<Suspense> <Suspense>
<Footer /> <Footer />
</Suspense> </Suspense>
</Suspense>
</> </>
); );
} }

View File

@ -82,7 +82,7 @@ export default async function ProductPage({ params }: { params: { handle: string
__html: JSON.stringify(productJsonLd) __html: JSON.stringify(productJsonLd)
}} }}
/> />
<div className="mx-auto max-w-screen-2xl px-4"> <div className="mx-auto mt-4 max-w-screen-2xl px-4">
<div className="hidden lg:block"> <div className="hidden lg:block">
<BreadcrumbComponent type="product" handle={product.handle} /> <BreadcrumbComponent type="product" handle={product.handle} />
</div> </div>

View File

@ -4,7 +4,7 @@ import { getCollection } from 'lib/shopify';
export const runtime = 'edge'; export const runtime = 'edge';
export default async function Image({ params }: { params: { collection: string } }) { export default async function Image({ params }: { params: { collection: string } }) {
const collection = await getCollection(params.collection); const collection = await getCollection({ handle: params.collection });
const title = collection?.seo?.title || collection?.title; const title = collection?.seo?.title || collection?.title;
return await OpengraphImage({ title }); return await OpengraphImage({ title });

View File

@ -23,7 +23,7 @@ export async function generateMetadata({
}: { }: {
params: { collection: string }; params: { collection: string };
}): Promise<Metadata> { }): Promise<Metadata> {
const collection = await getCollection(params.collection); const collection = await getCollection({ handle: params.collection });
if (!collection) return notFound(); if (!collection) return notFound();
@ -86,11 +86,11 @@ export default function CategorySearchPage(props: {
return ( return (
<> <>
<div className="mb-2"> <div className="mb-2">
<Suspense fallback={<BreadcrumbHome />} key={props.params.collection}> <Suspense fallback={<BreadcrumbHome />} key={`breadcrumb-${props.params.collection}`}>
<Breadcrumb type="collection" handle={props.params.collection} /> <Breadcrumb type="collection" handle={props.params.collection} />
</Suspense> </Suspense>
</div> </div>
<Suspense fallback={<HeaderPlaceholder />} key={props.params.collection}> <Suspense fallback={<HeaderPlaceholder />} key={`header-${props.params.collection}`}>
<Header collection={props.params.collection} /> <Header collection={props.params.collection} />
</Suspense> </Suspense>
<div className="my-3"> <div className="my-3">
@ -98,7 +98,7 @@ export default function CategorySearchPage(props: {
<YMMFilters /> <YMMFilters />
</Suspense> </Suspense>
</div> </div>
<Suspense fallback={<ProductsGridPlaceholder />} key={props.params.collection}> <Suspense fallback={<ProductsGridPlaceholder />} key={`products-${props.params.collection}`}>
<CategoryPage {...props} /> <CategoryPage {...props} />
</Suspense> </Suspense>
</> </>

View File

@ -3,7 +3,7 @@ import Footer from 'components/layout/footer';
export default function SearchLayout({ children }: { children: React.ReactNode }) { export default function SearchLayout({ children }: { children: React.ReactNode }) {
return ( return (
<> <>
<div className="mx-auto min-h-[500px] max-w-screen-2xl px-8 pb-4 lg:min-h-[800px]"> <div className="mx-auto mt-4 min-h-[500px] max-w-screen-2xl px-8 pb-4 lg:min-h-[800px]">
{children} {children}
</div> </div>
<Footer /> <Footer />

View File

@ -37,7 +37,7 @@ const BreadcrumbComponent = async ({ type, handle }: BreadcrumbProps) => {
} }
if (type === 'collection') { if (type === 'collection') {
const collectionData = getCollection(handle); const collectionData = getCollection({ handle });
const menuData = getMenu('main-menu'); const menuData = getMenu('main-menu');
const [collection, menu] = await Promise.all([collectionData, menuData]); const [collection, menu] = await Promise.all([collectionData, menuData]);
if (!collection) return null; if (!collection) return null;

View File

@ -21,6 +21,7 @@ type FilterFieldProps<T extends { [key: string]: unknown }> = {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
getId: (option: T) => string; getId: (option: T) => string;
disabled?: boolean; disabled?: boolean;
autoFocus?: boolean;
}; };
const FilterField = <T extends { [key: string]: unknown }>({ const FilterField = <T extends { [key: string]: unknown }>({
@ -30,7 +31,8 @@ const FilterField = <T extends { [key: string]: unknown }>({
label, label,
displayKey = 'name', displayKey = 'name',
getId, getId,
disabled disabled,
autoFocus = false
}: FilterFieldProps<T>) => { }: FilterFieldProps<T>) => {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const getDisplayValue = useCallback( const getDisplayValue = useCallback(
@ -69,7 +71,8 @@ const FilterField = <T extends { [key: string]: unknown }>({
displayValue={getDisplayValue} displayValue={getDisplayValue}
placeholder={`Select ${label}`} placeholder={`Select ${label}`}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
className="w-full rounded border border-gray-200 py-1.5 pl-3 pr-8 text-sm ring-2 ring-transparent focus:outline-none focus-visible:outline-none data-[disabled]:cursor-not-allowed data-[focus]:border-transparent data-[disabled]:opacity-50 data-[focus]:ring-2 data-[focus]:ring-secondary data-[focus]:ring-offset-0" className="w-full rounded border border-gray-200 py-1.5 pl-3 pr-8 text-sm ring-2 ring-transparent focus:outline-none focus-visible:outline-none data-[disabled]:cursor-not-allowed data-[autofocus]:border-0 data-[focus]:border-transparent data-[disabled]:opacity-50 data-[focus]:ring-2 data-[autofocus]:ring-secondary data-[focus]:ring-secondary data-[focus]:ring-offset-0"
autoFocus={autoFocus}
/> />
<ComboboxButton className="group absolute inset-y-0 right-0 px-2.5 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"> <ComboboxButton className="group absolute inset-y-0 right-0 px-2.5 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50">
<ChevronDownIcon className="size-5 fill-black/60 group-data-[hover]:fill-black" /> <ChevronDownIcon className="size-5 fill-black/60 group-data-[hover]:fill-black" />

View File

@ -14,9 +14,10 @@ type FiltersListProps = {
models: Metaobject[]; models: Metaobject[];
makes: Metaobject[]; makes: Metaobject[];
menu: Menu[]; menu: Menu[];
autoFocusField?: string;
}; };
const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => { const FiltersList = ({ years, makes, models, menu, autoFocusField }: FiltersListProps) => {
const params = useParams<{ collection?: string }>(); const params = useParams<{ collection?: string }>();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -24,21 +25,26 @@ const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => {
const parentCollection = params.collection ? findParentCollection(menu, params.collection) : null; const parentCollection = params.collection ? findParentCollection(menu, params.collection) : null;
// get the active collection (if any) to identify the default part type. // get the active collection (if any) to identify the default part type.
// if a collection is a sub collection, we will find the parent. Normally in this case, the parent collection would either be transmissions or engines. // if a collection is a sub collection, we will find the parent. Normally in this case, the parent collection would either be transmissions or engines.
const _collection = parentCollection?.path.split('/').slice(-1)[0] || params.collection; const partTypeCollection = parentCollection?.path.split('/').slice(-1)[0] || params.collection;
const [partType, setPartType] = useState<{ label: string; value: string } | null>( const [partType, setPartType] = useState<{ label: string; value: string } | null>(
PART_TYPES.find((type) => type.value === _collection) || null PART_TYPES.find((type) => type.value === partTypeCollection) || null
);
const [year, setYear] = useState<Metaobject | null>(
(partType && years.find((y) => y.id === searchParams.get(YEAR_FILTER_ID))) || null
); );
const [make, setMake] = useState<Metaobject | null>( const [make, setMake] = useState<Metaobject | null>(
(year && makes.find((make) => make.id === searchParams.get(MAKE_FILTER_ID))) || null (partType &&
makes.find((make) =>
searchParams.get(MAKE_FILTER_ID)
? make.id === searchParams.get(MAKE_FILTER_ID)
: make.slug === params.collection
)) ||
null
); );
const [model, setModel] = useState<Metaobject | null>( const [model, setModel] = useState<Metaobject | null>(
(make && models.find((model) => model.id === searchParams.get(MODEL_FILTER_ID))) || null (make && models.find((model) => model.id === searchParams.get(MODEL_FILTER_ID))) || null
); );
const [year, setYear] = useState<Metaobject | null>(
(model && years.find((y) => y.id === searchParams.get(YEAR_FILTER_ID))) || null
);
const modelOptions = make ? models.filter((m) => get(m, 'make') === make.id) : models; const modelOptions = make ? models.filter((m) => get(m, 'make') === make.id) : models;
const yearOptions = model ? years.filter((y) => get(y, 'make_model') === model.id) : years; const yearOptions = model ? years.filter((y) => get(y, 'make_model') === model.id) : years;
@ -84,6 +90,7 @@ const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => {
options={PART_TYPES} options={PART_TYPES}
getId={(option) => option.value} getId={(option) => option.value}
displayKey="label" displayKey="label"
autoFocus={autoFocusField === 'partType'}
/> />
<FilterField <FilterField
label="Make" label="Make"
@ -92,6 +99,7 @@ const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => {
options={makes} options={makes}
getId={(option) => option.id} getId={(option) => option.id}
disabled={!partType} disabled={!partType}
autoFocus={autoFocusField === 'make'}
/> />
<FilterField <FilterField
label="Model" label="Model"
@ -100,6 +108,7 @@ const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => {
options={modelOptions} options={modelOptions}
getId={(option) => option.id} getId={(option) => option.id}
disabled={!make} disabled={!make}
autoFocus={autoFocusField === 'model'}
/> />
<FilterField <FilterField
label="Year" label="Year"
@ -108,6 +117,7 @@ const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => {
options={yearOptions} options={yearOptions}
getId={(option) => option.id} getId={(option) => option.id}
disabled={!model || !make} disabled={!model || !make}
autoFocus={autoFocusField === 'year'}
/> />
<Button <Button
onClick={onSearch} onClick={onSearch}

View File

@ -0,0 +1,29 @@
import { getMenu, getMetaobjects } from 'lib/shopify';
import FiltersList from './filters-list';
const HomePageFilters = async () => {
const yearsData = getMetaobjects('make_model_year_composite');
const modelsData = getMetaobjects('make_model_composite');
const makesData = getMetaobjects('make_composite');
const [years, models, makes] = await Promise.all([yearsData, modelsData, makesData]);
const menu = await getMenu('main-menu');
return (
<>
<h1 className="text-4xl font-bold tracking-tight text-white lg:text-6xl">
Find Your Car Part
</h1>
<div className="mt-5">
<FiltersList
years={years}
makes={makes}
models={models}
menu={menu}
autoFocusField="partType"
/>
</div>
</>
);
};
export default HomePageFilters;

78
components/hero.tsx Normal file
View File

@ -0,0 +1,78 @@
import {
ArrowPathRoundedSquareIcon,
CurrencyDollarIcon,
StarIcon,
TruckIcon
} from '@heroicons/react/24/outline';
import Image from 'next/image';
import { Suspense } from 'react';
import HomePageFilters from './filters/hompage-filters';
const offers = [
{
name: 'Flat Rate Shipping (Commercial Address)',
icon: TruckIcon
},
{
name: 'Up to 5 Years Unlimited Miles Warranty',
icon: ArrowPathRoundedSquareIcon
},
{
name: 'Excellent Customer Support',
icon: StarIcon
},
{
name: 'No Core Charge for 30 Days',
icon: CurrencyDollarIcon
}
];
const Hero = () => {
return (
<div className="flex flex-col border-b border-gray-200 lg:border-0">
<nav aria-label="Offers" className="order-last bg-white lg:order-first">
<div className="max-w-8xl mx-auto lg:px-8">
<ul
role="list"
className="grid grid-cols-1 divide-y divide-gray-200 lg:grid-cols-4 lg:divide-x lg:divide-y-0"
>
{offers.map((offer) => (
<li
key={offer.name}
className="flex w-full items-center justify-start px-4 lg:justify-center"
>
<offer.icon className="size-7 flex-shrink-0 text-secondary" />
<p className="px-3 py-5 text-sm font-medium text-gray-800">{offer.name}</p>
</li>
))}
</ul>
</div>
</nav>
<div className="bg-white">
<div className="relative bg-gray-900">
{/* Decorative image and overlay */}
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<Image
src="/hero-image.jpeg"
alt="Hero Image"
width={1247}
height={626}
priority
className="h-full w-full object-cover object-center"
/>
</div>
<div aria-hidden="true" className="absolute inset-0 bg-gray-900 opacity-60" />
<div className="relative mx-auto flex max-w-4xl flex-col items-center px-6 py-32 text-center sm:py-64 lg:px-0">
<Suspense>
<HomePageFilters />
</Suspense>
</div>
</div>
</div>
</div>
);
};
export default Hero;

View File

@ -0,0 +1,19 @@
import { getPage } from 'lib/shopify';
import PageContent from './page/page-content';
const HomePageContent = async () => {
const page = await getPage('home-page');
return (
<div className="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
<div className="flex flex-col space-y-16">
{page.metaobjects?.map((content) => (
<div key={content.id}>
<PageContent block={content} />
</div>
))}
</div>
</div>
);
};
export default HomePageContent;

View File

@ -15,7 +15,7 @@ export default async function Navbar() {
const menu = await getMenu('main-menu'); const menu = await getMenu('main-menu');
return ( return (
<nav className="relative mb-4 flex items-center justify-between bg-white pb-3 pt-4 dark:bg-neutral-900 md:pb-0"> <nav className="relative flex items-center justify-between bg-white pb-3 pt-4 dark:bg-neutral-900 md:pb-0">
<div className="block flex-none pl-4 md:hidden"> <div className="block flex-none pl-4 md:hidden">
<Suspense fallback={null}> <Suspense fallback={null}>
<MobileMenu menu={menu} /> <MobileMenu menu={menu} />

View File

@ -22,7 +22,7 @@ const MainMenu = ({ menu }: { menu: Menu[] }) => {
if (!item.items.length) { if (!item.items.length) {
return ( return (
<Link <Link
key={item.title} key={`navbar-${item.title}-${item.path}`}
href={item.path} href={item.path}
className={`flex h-full items-center ${isActiveItem ? 'text-black' : 'text-neutral-600 hover:text-black'}`} className={`flex h-full items-center ${isActiveItem ? 'text-black' : 'text-neutral-600 hover:text-black'}`}
> >
@ -33,7 +33,7 @@ const MainMenu = ({ menu }: { menu: Menu[] }) => {
const isOpen = open === item.path; const isOpen = open === item.path;
return ( return (
<Popover key={item.title} className="relative flex h-full"> <Popover key={`navbar-${item.title}-${item.path}`} className="relative flex h-full">
<div <div
className="relative flex" className="relative flex"
onMouseOver={() => setOpen(item.path)} onMouseOver={() => setOpen(item.path)}
@ -69,7 +69,7 @@ const MainMenu = ({ menu }: { menu: Menu[] }) => {
<div className="overflow-hidden rounded-md shadow-lg ring-1 ring-black/5"> <div className="overflow-hidden rounded-md shadow-lg ring-1 ring-black/5">
<ul className="flex flex-col space-y-3 bg-white px-4 py-3"> <ul className="flex flex-col space-y-3 bg-white px-4 py-3">
{item.items.map((subItem: Menu) => ( {item.items.map((subItem: Menu) => (
<li key={subItem.title}> <li key={`sub-nav-${subItem.title}-${subItem.path}`}>
<Link <Link
href={subItem.path} href={subItem.path}
className={`border-b ${subItem.path === pathname ? 'border-black text-black' : 'border-transparent text-neutral-600 hover:text-black'}`} className={`border-b ${subItem.path === pathname ? 'border-black text-black' : 'border-transparent text-neutral-600 hover:text-black'}`}

View File

@ -1,7 +1,7 @@
import { getCollection } from 'lib/shopify'; import { getCollection } from 'lib/shopify';
const Header = async ({ collection }: { collection: string }) => { const Header = async ({ collection }: { collection: string }) => {
const collectionData = await getCollection(collection); const collectionData = await getCollection({ handle: collection });
return collectionData ? ( return collectionData ? (
<div className="mb-3 mt-3 max-w-5xl lg:mb-1"> <div className="mb-3 mt-3 max-w-5xl lg:mb-1">

View File

@ -23,7 +23,7 @@ const AccordionBlock = async ({ block }: { block: Metaobject }) => {
const accordionItemIds = JSON.parse(block.accordion || '[]') as string[]; const accordionItemIds = JSON.parse(block.accordion || '[]') as string[];
return ( return (
<div className="divide-y divide-gray-900/10"> <div className="divide-y divide-gray-900/10 px-4 md:px-0">
{block.title && ( {block.title && (
<h3 className="mb-7 text-xl font-semibold leading-6 text-gray-900">{block.title}</h3> <h3 className="mb-7 text-xl font-semibold leading-6 text-gray-900">{block.title}</h3>
)} )}

View File

@ -0,0 +1,68 @@
import Grid from 'components/grid';
import { getCollection, getMetaobjects, getMetaobjectsByIds } from 'lib/shopify';
import { Metaobject } from 'lib/shopify/types';
import { Suspense } from 'react';
import ImageDisplay from './image-display';
import { computeLayoutClassnames } from './layout';
export const Category = async ({ collectionId }: { collectionId: string }) => {
const collection = await getCollection({ id: collectionId });
return (
<h3 className="mt-4 text-base font-semibold text-gray-900">
<a href={collection?.path}>
<span className="absolute inset-0" />
{collection?.title}
</a>
</h3>
);
};
const CategoryPreview = async ({ block }: { block: Metaobject }) => {
const [contentBlocks, layouts, screenSizes] = await Promise.all([
getMetaobjectsByIds(block.categories ? JSON.parse(block.categories) : []),
getMetaobjectsByIds(block.layout ? JSON.parse(block.layout) : []),
getMetaobjects('screen_sizes')
]);
const validClassnames = computeLayoutClassnames({ layouts, screenSizes });
return (
<>
<div className="px-4 md:px-0">
<h2 className="text-2xl font-bold text-gray-900">{block.title}</h2>
<Grid className={`${validClassnames} mt-6`}>
{contentBlocks.map((contentBlock) => (
<div key={contentBlock.id} className="group relative">
<div className="relative h-80 w-full overflow-hidden rounded-lg bg-white sm:aspect-h-1 sm:aspect-w-2 lg:aspect-h-1 lg:aspect-w-1 group-hover:opacity-75 sm:h-64">
<Suspense>
<ImageDisplay
title={block.title || 'Image Preview'}
fileId={contentBlock.preview_image as string}
className="h-full w-full object-cover object-center"
/>
</Suspense>
</div>
<Suspense>
<Category collectionId={contentBlock.collection as string} />
</Suspense>
</div>
))}
</Grid>
</div>
</>
);
};
export const CategoryPreviewPlaceholder = () => {
return (
<div className="mx-auto max-w-2xl lg:max-w-none">
<Grid className="grid animate-pulse grid-cols-3 gap-y-16">
<div className="h-64 w-full rounded-lg bg-gray-200" />
<div className="h-64 w-full rounded-lg bg-gray-200" />
<div className="h-64 w-full rounded-lg bg-gray-200" />
</Grid>
</div>
);
};
export default CategoryPreview;

View File

@ -1,7 +1,8 @@
import Grid from 'components/grid'; import Grid from 'components/grid';
import DynamicHeroIcon from 'components/hero-icon'; import DynamicHeroIcon from 'components/hero-icon';
import { getMetaobjects, getMetaobjectsByIds } from 'lib/shopify'; import { getMetaobjects, getMetaobjectsByIds } from 'lib/shopify';
import { Metaobject, ScreenSize } from 'lib/shopify/types'; import { Metaobject } from 'lib/shopify/types';
import { computeLayoutClassnames } from './layout';
export const IconBlockPlaceholder = () => { export const IconBlockPlaceholder = () => {
return ( return (
@ -20,53 +21,7 @@ const IconWithTextBlock = async ({ block }: { block: Metaobject }) => {
getMetaobjects('screen_sizes') getMetaobjects('screen_sizes')
]); ]);
const availableLayouts = layouts.reduce( const validClassnames = computeLayoutClassnames({ layouts, screenSizes });
(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 ( return (
<div className="flex flex-col gap-5 px-4 md:px-0"> <div className="flex flex-col gap-5 px-4 md:px-0">
@ -74,15 +29,23 @@ const IconWithTextBlock = async ({ block }: { block: Metaobject }) => {
<h3 className="text-xl font-semibold leading-6 text-gray-900">{block.title}</h3> <h3 className="text-xl font-semibold leading-6 text-gray-900">{block.title}</h3>
) : null} ) : null}
<Grid className={validClassnames}> <Grid className={`${validClassnames} gap-x-8`}>
{contentBlocks.map((block) => ( {contentBlocks.map((block) => (
<Grid.Item key={block.id} className="flex flex-col gap-2"> <div key={block.id} className="items-center sm:flex">
{block.icon_name && ( {block.icon_name && (
<div className="sm:flex-shrink-0">
<div className="flow-root">
<DynamicHeroIcon icon={block.icon_name} className="w-16 text-secondary" /> <DynamicHeroIcon icon={block.icon_name} className="w-16 text-secondary" />
</div>
</div>
)} )}
{block.title && <div className="text-lg font-medium">{block.title}</div>} <div className="mt-3 sm:ml-4 sm:mt-0">
{block.content && <p className="text-base text-gray-800">{block.content}</p>} {block.title && (
</Grid.Item> <div className="text-sm font-medium text-gray-900">{block.title}</div>
)}
{block.content && <p className="mt-2 text-sm text-gray-500">{block.content}</p>}
</div>
</div>
))} ))}
</Grid> </Grid>
</div> </div>

View File

@ -1,7 +1,16 @@
import { getImage } from 'lib/shopify'; import { getImage } from 'lib/shopify';
import Image from 'next/image'; import Image from 'next/image';
import { twMerge } from 'tailwind-merge';
const ImageDisplay = async ({ fileId, title }: { fileId: string; title: string }) => { const ImageDisplay = async ({
fileId,
title,
className
}: {
fileId: string;
title: string;
className?: string;
}) => {
const image = await getImage(fileId); const image = await getImage(fileId);
return ( return (
<Image <Image
@ -9,7 +18,7 @@ const ImageDisplay = async ({ fileId, title }: { fileId: string; title: string }
alt={image.altText || `Display Image for ${title} section`} alt={image.altText || `Display Image for ${title} section`}
width={image.width} width={image.width}
height={image.height} height={image.height}
className="h-full w-full object-contain" className={twMerge('h-full w-full object-contain', className)}
/> />
); );
}; };

View File

@ -12,7 +12,7 @@ const ImageWithTextBlock = ({ block }: { block: Metaobject }) => {
<h3 className="text-xl font-semibold leading-6 text-gray-900">{block.title}</h3> <h3 className="text-xl font-semibold leading-6 text-gray-900">{block.title}</h3>
)} )}
{description ? ( {description ? (
<div className="grid grid-cols-1 gap-5 md:grid-cols-3"> <div className="grid grid-cols-1 gap-x-0 gap-y-5 md:grid-cols-3 md:gap-x-5 md:gap-y-0">
<div className="relative col-span-1"> <div className="relative col-span-1">
<Suspense> <Suspense>
<ImageDisplay title={block.title || 'Image Preview'} fileId={block.file as string} /> <ImageDisplay title={block.title || 'Image Preview'} fileId={block.file as string} />

59
components/page/layout.ts Normal file
View File

@ -0,0 +1,59 @@
import { Metaobject, ScreenSize } from 'lib/shopify/types';
export const computeLayoutClassnames = ({
layouts,
screenSizes
}: {
layouts: Metaobject[];
screenSizes: Metaobject[];
}) => {
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 sm:gap-x-8 sm:gap-y-12': availableLayouts.small === 1,
'sm:grid-cols-2 sm:gap-y-16': availableLayouts.small === 2,
'sm:grid-cols-3 sm:gap-y-16': availableLayouts.small === 3,
'sm:grid-cols-4 sm:gap-y-16': availableLayouts.small === 4
};
}
if (availableLayouts.medium) {
classnames = {
...classnames,
'md:grid-cols-1 md:gap-x-8 md:gap-y-12': availableLayouts.medium === 1,
'md:grid-cols-2 md:gap-y-16': availableLayouts.medium === 2,
'md:grid-cols-3 md:gap-y-16': availableLayouts.medium === 3,
'md:grid-cols-4 md:gap-y-16': availableLayouts.medium === 4
};
}
if (availableLayouts.large) {
classnames = {
...classnames,
'lg:grid-cols-1 lg:gap-x-8 lg:gap-y-12': availableLayouts.large === 1,
'lg:grid-cols-2 lg:gap-y-16': availableLayouts.large === 2,
'lg:grid-cols-3 lg:gap-y-16': availableLayouts.large === 3,
'lg:grid-cols-4 lg:gap-y-16': availableLayouts.large === 4
};
}
const validClassnames = Object.keys(classnames)
.filter((key) => classnames[key])
.join(' ');
return validClassnames;
};

View File

@ -1,6 +1,7 @@
import { Metaobject, PageType } from 'lib/shopify/types'; import { Metaobject, PageType } from 'lib/shopify/types';
import { Suspense } from 'react'; import { Suspense } from 'react';
import AccordionBlock from './accordion-block'; import AccordionBlock from './accordion-block';
import CategoryPreview, { CategoryPreviewPlaceholder } from './category-preview';
import IconWithTextBlock, { IconBlockPlaceholder } from './icon-with-text-block'; import IconWithTextBlock, { IconBlockPlaceholder } from './icon-with-text-block';
import ImageWithTextBlock from './image-with-text-block'; import ImageWithTextBlock from './image-with-text-block';
import TextBlock from './text-block'; import TextBlock from './text-block';
@ -15,7 +16,12 @@ const PageContent = ({ block }: { block: Metaobject }) => {
), ),
image: (block) => <ImageWithTextBlock block={block} />, image: (block) => <ImageWithTextBlock block={block} />,
page_section: (block) => <TextBlock block={block} />, page_section: (block) => <TextBlock block={block} />,
accordion: (block) => <AccordionBlock block={block} /> accordion: (block) => <AccordionBlock block={block} />,
category_preview: (block) => (
<Suspense fallback={<CategoryPreviewPlaceholder />}>
<CategoryPreview block={block} />
</Suspense>
)
}; };
return contentMap[block.type as PageType](block); return contentMap[block.type as PageType](block);

View File

@ -380,12 +380,19 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
return reshapeCart(res.body.data.cart); return reshapeCart(res.body.data.cart);
} }
export async function getCollection(handle: string): Promise<Collection | undefined> { export async function getCollection({
handle,
id
}: {
handle?: string;
id?: string;
}): Promise<Collection | undefined> {
const res = await shopifyFetch<ShopifyCollectionOperation>({ const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery, query: getCollectionQuery,
tags: [TAGS.collections], tags: [TAGS.collections],
variables: { variables: {
handle handle,
id
} }
}); });

View File

@ -15,8 +15,8 @@ const collectionFragment = /* GraphQL */ `
`; `;
export const getCollectionQuery = /* GraphQL */ ` export const getCollectionQuery = /* GraphQL */ `
query getCollection($handle: String!) { query getCollection($handle: String, $id: ID) {
collection(handle: $handle) { collection(handle: $handle, id: $id) {
...collection ...collection
} }
} }

View File

@ -56,7 +56,13 @@ export type PageMetafield = {
value: string; value: string;
}; };
export const PAGE_TYPES = ['image', 'icon_content_section', 'page_section', 'accordion'] as const; export const PAGE_TYPES = [
'image',
'icon_content_section',
'page_section',
'accordion',
'category_preview'
] as const;
export type PageType = (typeof PAGE_TYPES)[number]; export type PageType = (typeof PAGE_TYPES)[number];
export type ShopifyPage = { export type ShopifyPage = {
@ -246,7 +252,8 @@ export type ShopifyCollectionOperation = {
collection: ShopifyCollection; collection: ShopifyCollection;
}; };
variables: { variables: {
handle: string; handle?: string;
id?: string;
}; };
}; };

BIN
public/hero-image.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

View File

@ -58,6 +58,7 @@ module.exports = {
); );
}), }),
require('@tailwindcss/forms'), require('@tailwindcss/forms'),
require('@tailwindcss/aspect-ratio') require('@tailwindcss/aspect-ratio'),
require('@tailwindcss/container-queries')
] ]
}; };