diff --git a/app/page.tsx b/app/page.tsx index aefd19396..fad56a392 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,5 @@ import { Carousel } from 'components/carousel'; +import YMMFilters, { YMMFiltersPlaceholder } from 'components/filters'; import { ThreeItemGrid } from 'components/grid/three-items'; import Footer from 'components/layout/footer'; import { Suspense } from 'react'; @@ -15,6 +16,11 @@ export const metadata = { export default async function HomePage() { return ( <> +
+ }> + + +
diff --git a/app/search/[collection]/page.tsx b/app/search/[collection]/page.tsx index 7318486d1..e93fef258 100644 --- a/app/search/[collection]/page.tsx +++ b/app/search/[collection]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation'; import Breadcrumb from 'components/breadcrumb'; import BreadcrumbHome from 'components/breadcrumb/breadcrumb-home'; +import YMMFilters, { YMMFiltersPlaceholder } from 'components/filters'; import Grid from 'components/grid'; import ProductsList from 'components/layout/products-list'; import { getProductsInCollection } from 'components/layout/products-list/actions'; @@ -92,6 +93,11 @@ export default function CategorySearchPage(props: { }>
+
+ }> + + +
}> diff --git a/app/search/page.tsx b/app/search/page.tsx index c64ecb3fc..b8ac7df27 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -1,7 +1,9 @@ +import YMMFilters, { YMMFiltersPlaceholder } from 'components/filters'; import Grid from 'components/grid'; import ProductsList from 'components/layout/products-list'; import { searchProducts } from 'components/layout/products-list/actions'; import SortingMenu from 'components/layout/search/sorting-menu'; +import { Suspense } from 'react'; export const runtime = 'edge'; export const metadata = { @@ -20,7 +22,10 @@ export default async function SearchPage({ return ( <> -
+ }> + + +
{searchValue ? ( diff --git a/components/breadcrumb/index.tsx b/components/breadcrumb/index.tsx index dd44e842e..2e856c33e 100644 --- a/components/breadcrumb/index.tsx +++ b/components/breadcrumb/index.tsx @@ -1,5 +1,5 @@ import { getCollection, getMenu, getProduct } from 'lib/shopify'; -import { Menu } from 'lib/shopify/types'; +import { findParentCollection } from 'lib/utils'; import { Fragment } from 'react'; import { Breadcrumb, @@ -15,21 +15,6 @@ type BreadcrumbProps = { handle: string; }; -const findParentCollection = (menu: Menu[], collection: string): Menu | null => { - let parentCollection: Menu | null = null; - for (const item of menu) { - if (item.items.length) { - const hasParent = item.items.some((subItem) => subItem.path.includes(collection)); - if (hasParent) { - return item; - } else { - parentCollection = findParentCollection(item.items, collection); - } - } - } - return parentCollection; -}; - const BreadcrumbComponent = async ({ type, handle }: BreadcrumbProps) => { const items: Array<{ href: string; title: string }> = [{ href: '/', title: 'Home' }]; diff --git a/components/filters/field.tsx b/components/filters/field.tsx new file mode 100644 index 000000000..a81731a50 --- /dev/null +++ b/components/filters/field.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { + Combobox, + ComboboxButton, + ComboboxInput, + ComboboxOption, + ComboboxOptions +} from '@headlessui/react'; +import { ChevronDownIcon } from '@heroicons/react/16/solid'; +import get from 'lodash.get'; +import { useCallback, useState } from 'react'; + +type FilterFieldProps = { + options: T[]; + selectedValue: T | null; + // eslint-disable-next-line no-unused-vars + onChange: (value: T | null) => void; + label: string; + displayKey?: string; + // eslint-disable-next-line no-unused-vars + getId: (option: T) => string; + disabled?: boolean; +}; + +const FilterField = ({ + options, + selectedValue, + onChange, + label, + displayKey = 'name', + getId, + disabled +}: FilterFieldProps) => { + const [query, setQuery] = useState(''); + const getDisplayValue = useCallback( + (option: T | null) => { + if (!option) return ''; + + if (typeof option[displayKey] === 'string') { + return option[displayKey] as string; + } + + return get(option, `${displayKey}.value`) as string; + }, + [displayKey] + ); + + const filteredOptions = + query === '' + ? options + : options.filter((option) => { + return getDisplayValue(option).toLocaleLowerCase().includes(query.toLowerCase()); + }); + + return ( +
+ setQuery('')} + immediate + disabled={disabled} + > +
+ 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" + /> + + + +
+ + {filteredOptions.map((option) => ( + + {getDisplayValue(option)} + + ))} + +
+
+ ); +}; + +export default FilterField; diff --git a/components/filters/filters-list.tsx b/components/filters/filters-list.tsx new file mode 100644 index 000000000..ae32b00ed --- /dev/null +++ b/components/filters/filters-list.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { Button } from '@headlessui/react'; +import { MAKE_FILTER_ID, MODEL_FILTER_ID, PART_TYPES, YEAR_FILTER_ID } from 'lib/constants'; +import { Menu, Metaobject } from 'lib/shopify/types'; +import { createUrl, findParentCollection } from 'lib/utils'; +import get from 'lodash.get'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useState } from 'react'; +import FilterField from './field'; + +type FiltersListProps = { + years: Metaobject[]; + models: Metaobject[]; + makes: Metaobject[]; + menu: Menu[]; +}; + +const FiltersList = ({ years, makes, models, menu }: FiltersListProps) => { + const params = useParams<{ collection?: string }>(); + const router = useRouter(); + const searchParams = useSearchParams(); + + 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 [partType, setPartType] = useState<{ label: string; value: string } | null>( + PART_TYPES.find((type) => type.value === _collection) || null + ); + + const [year, setYear] = useState( + (partType && years.find((y) => y.id === searchParams.get(YEAR_FILTER_ID))) || null + ); + const [make, setMake] = useState( + (year && makes.find((make) => make.id === searchParams.get(MAKE_FILTER_ID))) || null + ); + const [model, setModel] = useState( + (make && models.find((model) => model.id === searchParams.get(MODEL_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; + + const disabled = !partType || !make || !model || !year; + + const onChangeMake = (value: Metaobject | null) => { + setMake(value); + setModel(null); + setYear(null); + }; + + const onChangeModel = (value: Metaobject | null) => { + setModel(value); + setYear(null); + }; + + const onChangeYear = (value: Metaobject | null) => { + setYear(value); + }; + + const onChangePartType = (value: { label: string; value: string } | null) => { + setPartType(value); + setMake(null); + setModel(null); + setYear(null); + }; + + const onSearch = () => { + const newSearchParams = new URLSearchParams(searchParams.toString()); + newSearchParams.set(MAKE_FILTER_ID, make?.id || ''); + newSearchParams.set(MODEL_FILTER_ID, model?.id || ''); + newSearchParams.set(YEAR_FILTER_ID, year?.id || ''); + router.push(createUrl(`/search/${partType?.value}`, newSearchParams), { scroll: false }); + }; + + return ( +
+ option.value} + displayKey="label" + /> + option.id} + disabled={!partType} + /> + option.id} + disabled={!make} + /> + option.id} + disabled={!model || !make} + /> + +
+ ); +}; + +export default FiltersList; diff --git a/components/filters/index.tsx b/components/filters/index.tsx new file mode 100644 index 000000000..40bf6f52f --- /dev/null +++ b/components/filters/index.tsx @@ -0,0 +1,43 @@ +import { getMenu, getMetaobjects } from 'lib/shopify'; +import { ReactNode } from 'react'; +import FiltersList from './filters-list'; + +const YMMFiltersContainer = ({ children }: { children: ReactNode }) => { + return ( +
+
+ Find Your Car Part +
+ {children} +
+ ); +}; + +const YMMFilters = 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 ( + + + + ); +}; + +export const YMMFiltersPlaceholder = () => { + return ( + +
+
+
+
+
+ + ); +}; + +export default YMMFilters; diff --git a/lib/constants.ts b/lib/constants.ts index 0706e5db0..2ec301cfd 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -20,6 +20,11 @@ export const sorting: SortFilterItem[] = [ { title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true } ]; +export const PART_TYPES = [ + { label: 'Transmissions', value: 'transmissions' }, + { label: 'Engines', value: 'engines' } +]; + export const TAGS = { collections: 'collections', products: 'products', @@ -35,5 +40,8 @@ export const CORE_VARIANT_ID_KEY = 'coreVariantId'; export const AVAILABILITY_FILTER_ID = 'filter.v.availability'; export const PRICE_FILTER_ID = 'filter.v.price'; +export const MAKE_FILTER_ID = 'filter.p.m.custom.make_composite'; +export const MODEL_FILTER_ID = 'filter.p.m.custom.make_model_composite'; +export const YEAR_FILTER_ID = 'filter.p.m.custom.make_model_year_composite'; export const PRODUCT_METAFIELD_PREFIX = 'filter.p.m'; export const VARIANT_METAFIELD_PREFIX = 'filter.v.m'; diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index a85bd49b8..2d7c71dda 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -1,11 +1,14 @@ import { AVAILABILITY_FILTER_ID, HIDDEN_PRODUCT_TAG, + MAKE_FILTER_ID, + MODEL_FILTER_ID, PRICE_FILTER_ID, PRODUCT_METAFIELD_PREFIX, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS, - VARIANT_METAFIELD_PREFIX + VARIANT_METAFIELD_PREFIX, + YEAR_FILTER_ID } from 'lib/constants'; import { isShopifyError } from 'lib/type-guards'; import { ensureStartsWith, normalizeUrl, parseMetaFieldValue } from 'lib/utils'; @@ -25,6 +28,7 @@ import { getCollectionsQuery } from './queries/collection'; import { getMenuQuery } from './queries/menu'; +import { getMetaobjectsQuery } from './queries/metaobject'; import { getPageQuery, getPagesQuery } from './queries/page'; import { getProductQuery, @@ -38,6 +42,7 @@ import { Filter, Image, Menu, + Metaobject, Money, Page, PageInfo, @@ -53,6 +58,8 @@ import { ShopifyCreateCartOperation, ShopifyFilter, ShopifyMenuOperation, + ShopifyMetaobject, + ShopifyMetaobjectsOperation, ShopifyPageOperation, ShopifyPagesOperation, ShopifyProduct, @@ -181,7 +188,10 @@ const reshapeCollections = (collections: ShopifyCollection[]) => { const reshapeFilters = (filters: ShopifyFilter[]): Filter[] => { const reshapedFilters = []; - for (const filter of filters) { + const excludedYMMFilters = filters.filter( + (filter) => ![MODEL_FILTER_ID, MAKE_FILTER_ID, YEAR_FILTER_ID].includes(filter.id) + ); + for (const filter of excludedYMMFilters) { const values = filter.values .map((valueItem) => { if (filter.id === AVAILABILITY_FILTER_ID) { @@ -222,6 +232,29 @@ const reshapeFilters = (filters: ShopifyFilter[]): Filter[] => { return reshapedFilters; }; +const reshapeMetaobjects = (metaobjects: ShopifyMetaobject[]): Metaobject[] => { + return metaobjects.map(({ fields, id }) => { + const groupedFieldsByKey = fields.reduce( + (acc, field) => { + return { + ...acc, + [field.key]: field.value + }; + }, + {} as { + [key: string]: + | { + value: string; + referenceId: string; + } + | string; + } + ); + + return { id, ...groupedFieldsByKey }; + }); +}; + const reshapeImages = (images: Connection, productTitle: string) => { const flattened = removeEdgesAndNodes(images); @@ -447,6 +480,16 @@ export async function getMenu(handle: string): Promise { return formatMenuItems(res.body?.data?.menu?.items); } +export async function getMetaobjects(type: string) { + const res = await shopifyFetch({ + query: getMetaobjectsQuery, + tags: [TAGS.collections, TAGS.products], + variables: { type } + }); + + return reshapeMetaobjects(removeEdgesAndNodes(res.body.data.metaobjects)); +} + export async function getPage(handle: string): Promise { const res = await shopifyFetch({ query: getPageQuery, diff --git a/lib/shopify/queries/metaobject.ts b/lib/shopify/queries/metaobject.ts new file mode 100644 index 000000000..03b463b46 --- /dev/null +++ b/lib/shopify/queries/metaobject.ts @@ -0,0 +1,20 @@ +export const getMetaobjectsQuery = /* GraphQL */ ` + query getMetaobjects($type: String!) { + metaobjects(type: $type, first: 200) { + edges { + node { + id + fields { + reference { + ... on Metaobject { + id + } + } + key + value + } + } + } + } + } +`; diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index 79ba849ae..3ef0cbe57 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -62,6 +62,22 @@ export type Page = { updatedAt: string; }; +export type ShopifyMetaobject = { + id: string; + fields: Array<{ + key: string; + value: string; + reference: { + id: string; + }; + }>; +}; + +export type Metaobject = { + id: string; + [key: string]: string; +}; + export type Product = Omit & { variants: ProductVariant[]; images: Image[]; @@ -269,6 +285,11 @@ export type ShopifyPageOperation = { variables: { handle: string }; }; +export type ShopifyMetaobjectsOperation = { + data: { metaobjects: Connection }; + variables: { type: string }; +}; + export type ShopifyPagesOperation = { data: { pages: Connection; diff --git a/lib/utils.ts b/lib/utils.ts index 9d2179caf..8d800e3f6 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,7 @@ import clsx, { ClassValue } from 'clsx'; import { ReadonlyURLSearchParams } from 'next/navigation'; import { twMerge } from 'tailwind-merge'; +import { Menu } from './shopify/types'; export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => { const paramsString = params.toString(); @@ -55,3 +56,18 @@ export const parseMetaFieldValue = (field: { value: string } | null): T | nul return null; } }; + +export const findParentCollection = (menu: Menu[], collection: string): Menu | null => { + let parentCollection: Menu | null = null; + for (const item of menu) { + if (item.items.length) { + const hasParent = item.items.some((subItem) => subItem.path.includes(collection)); + if (hasParent) { + return item; + } else { + parentCollection = findParentCollection(item.items, collection); + } + } + } + return parentCollection; +};