mirror of
https://github.com/vercel/commerce.git
synced 2025-05-12 20:57:51 +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 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');
|
||||||
openGraph: {
|
|
||||||
type: 'website'
|
return {
|
||||||
}
|
title: page?.seo?.title || page?.title,
|
||||||
};
|
description: page?.seo?.description || page?.bodySummary,
|
||||||
|
openGraph: {
|
||||||
|
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>
|
|
||||||
<Carousel />
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<Footer />
|
<HomePageContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<Suspense>
|
||||||
|
<Footer />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
|
@ -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 });
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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 />
|
||||||
|
@ -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;
|
||||||
|
@ -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" />
|
||||||
|
@ -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}
|
||||||
|
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');
|
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} />
|
||||||
|
@ -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'}`}
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
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 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 && (
|
||||||
<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>}
|
<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>
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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
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 { 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);
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
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/forms'),
|
||||||
require('@tailwindcss/aspect-ratio')
|
require('@tailwindcss/aspect-ratio'),
|
||||||
|
require('@tailwindcss/container-queries')
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user