mirror of
https://github.com/vercel/commerce.git
synced 2025-05-12 12:47:50 +00:00
feat: homepage and integrate with shopify page
Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
parent
e0da620ac9
commit
8f0801689c
39
app/page.tsx
39
app/page.tsx
@ -1,32 +1,35 @@
|
||||
import { Carousel } from 'components/carousel';
|
||||
import YMMFilters, { YMMFiltersPlaceholder } from 'components/filters';
|
||||
import { ThreeItemGrid } from 'components/grid/three-items';
|
||||
import Hero from 'components/hero';
|
||||
import HomePageContent from 'components/home-page-content';
|
||||
import Footer from 'components/layout/footer';
|
||||
import { getPage } from 'lib/shopify';
|
||||
import { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export const metadata = {
|
||||
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
|
||||
openGraph: {
|
||||
type: 'website'
|
||||
}
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const page = await getPage('home-page');
|
||||
|
||||
return {
|
||||
title: page?.seo?.title || page?.title,
|
||||
description: page?.seo?.description || page?.bodySummary,
|
||||
openGraph: {
|
||||
type: 'website'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<section className="mx-auto max-w-screen-2xl px-4 pb-4">
|
||||
<Suspense fallback={<YMMFiltersPlaceholder />}>
|
||||
<YMMFilters />
|
||||
</Suspense>
|
||||
</section>
|
||||
<ThreeItemGrid />
|
||||
<Suspense>
|
||||
<Carousel />
|
||||
<Hero />
|
||||
<div className="mt-3">
|
||||
<Suspense>
|
||||
<Footer />
|
||||
<HomePageContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense>
|
||||
<Footer />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
|
@ -82,7 +82,7 @@ export default async function ProductPage({ params }: { params: { handle: string
|
||||
__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">
|
||||
<BreadcrumbComponent type="product" handle={product.handle} />
|
||||
</div>
|
||||
|
@ -4,7 +4,7 @@ import { getCollection } from 'lib/shopify';
|
||||
export const runtime = 'edge';
|
||||
|
||||
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;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
|
@ -23,7 +23,7 @@ export async function generateMetadata({
|
||||
}: {
|
||||
params: { collection: string };
|
||||
}): Promise<Metadata> {
|
||||
const collection = await getCollection(params.collection);
|
||||
const collection = await getCollection({ handle: params.collection });
|
||||
|
||||
if (!collection) return notFound();
|
||||
|
||||
@ -86,11 +86,11 @@ export default function CategorySearchPage(props: {
|
||||
return (
|
||||
<>
|
||||
<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} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<HeaderPlaceholder />} key={props.params.collection}>
|
||||
<Suspense fallback={<HeaderPlaceholder />} key={`header-${props.params.collection}`}>
|
||||
<Header collection={props.params.collection} />
|
||||
</Suspense>
|
||||
<div className="my-3">
|
||||
@ -98,7 +98,7 @@ export default function CategorySearchPage(props: {
|
||||
<YMMFilters />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<ProductsGridPlaceholder />} key={props.params.collection}>
|
||||
<Suspense fallback={<ProductsGridPlaceholder />} key={`products-${props.params.collection}`}>
|
||||
<CategoryPage {...props} />
|
||||
</Suspense>
|
||||
</>
|
||||
|
@ -3,7 +3,7 @@ import Footer from 'components/layout/footer';
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
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}
|
||||
</div>
|
||||
<Footer />
|
||||
|
@ -37,7 +37,7 @@ const BreadcrumbComponent = async ({ type, handle }: BreadcrumbProps) => {
|
||||
}
|
||||
|
||||
if (type === 'collection') {
|
||||
const collectionData = getCollection(handle);
|
||||
const collectionData = getCollection({ handle });
|
||||
const menuData = getMenu('main-menu');
|
||||
const [collection, menu] = await Promise.all([collectionData, menuData]);
|
||||
if (!collection) return null;
|
||||
|
@ -21,6 +21,7 @@ type FilterFieldProps<T extends { [key: string]: unknown }> = {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
getId: (option: T) => string;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
const FilterField = <T extends { [key: string]: unknown }>({
|
||||
@ -30,7 +31,8 @@ const FilterField = <T extends { [key: string]: unknown }>({
|
||||
label,
|
||||
displayKey = 'name',
|
||||
getId,
|
||||
disabled
|
||||
disabled,
|
||||
autoFocus = false
|
||||
}: FilterFieldProps<T>) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const getDisplayValue = useCallback(
|
||||
@ -69,7 +71,8 @@ const FilterField = <T extends { [key: string]: unknown }>({
|
||||
displayValue={getDisplayValue}
|
||||
placeholder={`Select ${label}`}
|
||||
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">
|
||||
<ChevronDownIcon className="size-5 fill-black/60 group-data-[hover]:fill-black" />
|
||||
|
@ -14,9 +14,10 @@ type FiltersListProps = {
|
||||
models: Metaobject[];
|
||||
makes: Metaobject[];
|
||||
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 router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@ -24,21 +25,26 @@ const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => {
|
||||
const parentCollection = params.collection ? findParentCollection(menu, params.collection) : null;
|
||||
// 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.
|
||||
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>(
|
||||
PART_TYPES.find((type) => type.value === _collection) || null
|
||||
);
|
||||
|
||||
const [year, setYear] = useState<Metaobject | null>(
|
||||
(partType && years.find((y) => y.id === searchParams.get(YEAR_FILTER_ID))) || null
|
||||
PART_TYPES.find((type) => type.value === partTypeCollection) || 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>(
|
||||
(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 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}
|
||||
getId={(option) => option.value}
|
||||
displayKey="label"
|
||||
autoFocus={autoFocusField === 'partType'}
|
||||
/>
|
||||
<FilterField
|
||||
label="Make"
|
||||
@ -92,6 +99,7 @@ const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => {
|
||||
options={makes}
|
||||
getId={(option) => option.id}
|
||||
disabled={!partType}
|
||||
autoFocus={autoFocusField === 'make'}
|
||||
/>
|
||||
<FilterField
|
||||
label="Model"
|
||||
@ -100,6 +108,7 @@ const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => {
|
||||
options={modelOptions}
|
||||
getId={(option) => option.id}
|
||||
disabled={!make}
|
||||
autoFocus={autoFocusField === 'model'}
|
||||
/>
|
||||
<FilterField
|
||||
label="Year"
|
||||
@ -108,6 +117,7 @@ const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => {
|
||||
options={yearOptions}
|
||||
getId={(option) => option.id}
|
||||
disabled={!model || !make}
|
||||
autoFocus={autoFocusField === 'year'}
|
||||
/>
|
||||
<Button
|
||||
onClick={onSearch}
|
||||
|
29
components/filters/hompage-filters.tsx
Normal file
29
components/filters/hompage-filters.tsx
Normal 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
78
components/hero.tsx
Normal 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;
|
19
components/home-page-content.tsx
Normal file
19
components/home-page-content.tsx
Normal 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;
|
@ -15,7 +15,7 @@ export default async function Navbar() {
|
||||
const menu = await getMenu('main-menu');
|
||||
|
||||
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">
|
||||
<Suspense fallback={null}>
|
||||
<MobileMenu menu={menu} />
|
||||
|
@ -22,7 +22,7 @@ const MainMenu = ({ menu }: { menu: Menu[] }) => {
|
||||
if (!item.items.length) {
|
||||
return (
|
||||
<Link
|
||||
key={item.title}
|
||||
key={`navbar-${item.title}-${item.path}`}
|
||||
href={item.path}
|
||||
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;
|
||||
return (
|
||||
<Popover key={item.title} className="relative flex h-full">
|
||||
<Popover key={`navbar-${item.title}-${item.path}`} className="relative flex h-full">
|
||||
<div
|
||||
className="relative flex"
|
||||
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">
|
||||
<ul className="flex flex-col space-y-3 bg-white px-4 py-3">
|
||||
{item.items.map((subItem: Menu) => (
|
||||
<li key={subItem.title}>
|
||||
<li key={`sub-nav-${subItem.title}-${subItem.path}`}>
|
||||
<Link
|
||||
href={subItem.path}
|
||||
className={`border-b ${subItem.path === pathname ? 'border-black text-black' : 'border-transparent text-neutral-600 hover:text-black'}`}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { getCollection } from 'lib/shopify';
|
||||
|
||||
const Header = async ({ collection }: { collection: string }) => {
|
||||
const collectionData = await getCollection(collection);
|
||||
const collectionData = await getCollection({ handle: collection });
|
||||
|
||||
return collectionData ? (
|
||||
<div className="mb-3 mt-3 max-w-5xl lg:mb-1">
|
||||
|
@ -23,7 +23,7 @@ const AccordionBlock = async ({ block }: { block: Metaobject }) => {
|
||||
const accordionItemIds = JSON.parse(block.accordion || '[]') as string[];
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-900/10">
|
||||
<div className="divide-y divide-gray-900/10 px-4 md:px-0">
|
||||
{block.title && (
|
||||
<h3 className="mb-7 text-xl font-semibold leading-6 text-gray-900">{block.title}</h3>
|
||||
)}
|
||||
|
68
components/page/category-preview.tsx
Normal file
68
components/page/category-preview.tsx
Normal 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;
|
@ -1,7 +1,8 @@
|
||||
import Grid from 'components/grid';
|
||||
import DynamicHeroIcon from 'components/hero-icon';
|
||||
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 = () => {
|
||||
return (
|
||||
@ -20,53 +21,7 @@ const IconWithTextBlock = async ({ block }: { block: Metaobject }) => {
|
||||
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(' ');
|
||||
const validClassnames = computeLayoutClassnames({ layouts, screenSizes });
|
||||
|
||||
return (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
<Grid className={validClassnames}>
|
||||
<Grid className={`${validClassnames} gap-x-8`}>
|
||||
{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 && (
|
||||
<DynamicHeroIcon icon={block.icon_name} className="w-16 text-secondary" />
|
||||
<div className="sm:flex-shrink-0">
|
||||
<div className="flow-root">
|
||||
<DynamicHeroIcon icon={block.icon_name} className="w-16 text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{block.title && <div className="text-lg font-medium">{block.title}</div>}
|
||||
{block.content && <p className="text-base text-gray-800">{block.content}</p>}
|
||||
</Grid.Item>
|
||||
<div className="mt-3 sm:ml-4 sm:mt-0">
|
||||
{block.title && (
|
||||
<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>
|
||||
</div>
|
||||
|
@ -1,7 +1,16 @@
|
||||
import { getImage } from 'lib/shopify';
|
||||
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);
|
||||
return (
|
||||
<Image
|
||||
@ -9,7 +18,7 @@ const ImageDisplay = async ({ fileId, title }: { fileId: string; title: string }
|
||||
alt={image.altText || `Display Image for ${title} section`}
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
className="h-full w-full object-contain"
|
||||
className={twMerge('h-full w-full object-contain', className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -12,7 +12,7 @@ const ImageWithTextBlock = ({ block }: { block: Metaobject }) => {
|
||||
<h3 className="text-xl font-semibold leading-6 text-gray-900">{block.title}</h3>
|
||||
)}
|
||||
{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">
|
||||
<Suspense>
|
||||
<ImageDisplay title={block.title || 'Image Preview'} fileId={block.file as string} />
|
||||
|
59
components/page/layout.ts
Normal file
59
components/page/layout.ts
Normal 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;
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
import { Metaobject, PageType } from 'lib/shopify/types';
|
||||
import { Suspense } from 'react';
|
||||
import AccordionBlock from './accordion-block';
|
||||
import CategoryPreview, { CategoryPreviewPlaceholder } from './category-preview';
|
||||
import IconWithTextBlock, { IconBlockPlaceholder } from './icon-with-text-block';
|
||||
import ImageWithTextBlock from './image-with-text-block';
|
||||
import TextBlock from './text-block';
|
||||
@ -15,7 +16,12 @@ const PageContent = ({ block }: { block: Metaobject }) => {
|
||||
),
|
||||
image: (block) => <ImageWithTextBlock 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);
|
||||
|
@ -380,12 +380,19 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
|
||||
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>({
|
||||
query: getCollectionQuery,
|
||||
tags: [TAGS.collections],
|
||||
variables: {
|
||||
handle
|
||||
handle,
|
||||
id
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -15,8 +15,8 @@ const collectionFragment = /* GraphQL */ `
|
||||
`;
|
||||
|
||||
export const getCollectionQuery = /* GraphQL */ `
|
||||
query getCollection($handle: String!) {
|
||||
collection(handle: $handle) {
|
||||
query getCollection($handle: String, $id: ID) {
|
||||
collection(handle: $handle, id: $id) {
|
||||
...collection
|
||||
}
|
||||
}
|
||||
|
@ -56,7 +56,13 @@ export type PageMetafield = {
|
||||
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 ShopifyPage = {
|
||||
@ -246,7 +252,8 @@ export type ShopifyCollectionOperation = {
|
||||
collection: ShopifyCollection;
|
||||
};
|
||||
variables: {
|
||||
handle: string;
|
||||
handle?: string;
|
||||
id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
BIN
public/hero-image.jpeg
Normal file
BIN
public/hero-image.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 517 KiB |
@ -58,6 +58,7 @@ module.exports = {
|
||||
);
|
||||
}),
|
||||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/aspect-ratio')
|
||||
require('@tailwindcss/aspect-ratio'),
|
||||
require('@tailwindcss/container-queries')
|
||||
]
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user