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