mirror of
https://github.com/vercel/commerce.git
synced 2025-05-18 23:46:58 +00:00
feat: implement YMM filters
Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
parent
57c5f42bf3
commit
7c60e4e7f4
@ -1,4 +1,5 @@
|
|||||||
import { Carousel } from 'components/carousel';
|
import { Carousel } from 'components/carousel';
|
||||||
|
import YMMFilters, { YMMFiltersPlaceholder } from 'components/filters';
|
||||||
import { ThreeItemGrid } from 'components/grid/three-items';
|
import { ThreeItemGrid } from 'components/grid/three-items';
|
||||||
import Footer from 'components/layout/footer';
|
import Footer from 'components/layout/footer';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
@ -15,6 +16,11 @@ export const metadata = {
|
|||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<section className="mx-auto max-w-screen-2xl px-4 pb-4">
|
||||||
|
<Suspense fallback={<YMMFiltersPlaceholder />}>
|
||||||
|
<YMMFilters />
|
||||||
|
</Suspense>
|
||||||
|
</section>
|
||||||
<ThreeItemGrid />
|
<ThreeItemGrid />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<Carousel />
|
<Carousel />
|
||||||
|
@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
|
|||||||
|
|
||||||
import Breadcrumb from 'components/breadcrumb';
|
import Breadcrumb from 'components/breadcrumb';
|
||||||
import BreadcrumbHome from 'components/breadcrumb/breadcrumb-home';
|
import BreadcrumbHome from 'components/breadcrumb/breadcrumb-home';
|
||||||
|
import YMMFilters, { YMMFiltersPlaceholder } from 'components/filters';
|
||||||
import Grid from 'components/grid';
|
import Grid from 'components/grid';
|
||||||
import ProductsList from 'components/layout/products-list';
|
import ProductsList from 'components/layout/products-list';
|
||||||
import { getProductsInCollection } from 'components/layout/products-list/actions';
|
import { getProductsInCollection } from 'components/layout/products-list/actions';
|
||||||
@ -92,6 +93,11 @@ export default function CategorySearchPage(props: {
|
|||||||
<Suspense fallback={<HeaderPlaceholder />}>
|
<Suspense fallback={<HeaderPlaceholder />}>
|
||||||
<Header collection={props.params.collection} />
|
<Header collection={props.params.collection} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<div className="my-3">
|
||||||
|
<Suspense fallback={<YMMFiltersPlaceholder />}>
|
||||||
|
<YMMFilters />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
<Suspense fallback={<ProductsGridPlaceholder />}>
|
<Suspense fallback={<ProductsGridPlaceholder />}>
|
||||||
<CategoryPage {...props} />
|
<CategoryPage {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
import YMMFilters, { YMMFiltersPlaceholder } from 'components/filters';
|
||||||
import Grid from 'components/grid';
|
import Grid from 'components/grid';
|
||||||
import ProductsList from 'components/layout/products-list';
|
import ProductsList from 'components/layout/products-list';
|
||||||
import { searchProducts } from 'components/layout/products-list/actions';
|
import { searchProducts } from 'components/layout/products-list/actions';
|
||||||
import SortingMenu from 'components/layout/search/sorting-menu';
|
import SortingMenu from 'components/layout/search/sorting-menu';
|
||||||
|
import { Suspense } from 'react';
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -20,7 +22,10 @@ export default async function SearchPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full justify-end">
|
<Suspense fallback={<YMMFiltersPlaceholder />}>
|
||||||
|
<YMMFilters />
|
||||||
|
</Suspense>
|
||||||
|
<div className="my-3 flex w-full justify-end">
|
||||||
<SortingMenu />
|
<SortingMenu />
|
||||||
</div>
|
</div>
|
||||||
{searchValue ? (
|
{searchValue ? (
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { getCollection, getMenu, getProduct } from 'lib/shopify';
|
import { getCollection, getMenu, getProduct } from 'lib/shopify';
|
||||||
import { Menu } from 'lib/shopify/types';
|
import { findParentCollection } from 'lib/utils';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@ -15,21 +15,6 @@ type BreadcrumbProps = {
|
|||||||
handle: string;
|
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 BreadcrumbComponent = async ({ type, handle }: BreadcrumbProps) => {
|
||||||
const items: Array<{ href: string; title: string }> = [{ href: '/', title: 'Home' }];
|
const items: Array<{ href: string; title: string }> = [{ href: '/', title: 'Home' }];
|
||||||
|
|
||||||
|
97
components/filters/field.tsx
Normal file
97
components/filters/field.tsx
Normal file
@ -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<T extends { [key: string]: unknown }> = {
|
||||||
|
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 = <T extends { [key: string]: unknown }>({
|
||||||
|
options,
|
||||||
|
selectedValue,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
displayKey = 'name',
|
||||||
|
getId,
|
||||||
|
disabled
|
||||||
|
}: FilterFieldProps<T>) => {
|
||||||
|
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 (
|
||||||
|
<div className="w-full">
|
||||||
|
<Combobox
|
||||||
|
value={selectedValue}
|
||||||
|
by={displayKey}
|
||||||
|
onChange={onChange}
|
||||||
|
onClose={() => setQuery('')}
|
||||||
|
immediate
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<ComboboxInput
|
||||||
|
aria-label={label}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<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" />
|
||||||
|
</ComboboxButton>
|
||||||
|
</div>
|
||||||
|
<ComboboxOptions
|
||||||
|
anchor="bottom"
|
||||||
|
className="w-[var(--input-width)] rounded-xl border border-gray-200 bg-white p-1 [--anchor-gap:6px] empty:hidden"
|
||||||
|
>
|
||||||
|
{filteredOptions.map((option) => (
|
||||||
|
<ComboboxOption
|
||||||
|
key={getId(option)}
|
||||||
|
value={option}
|
||||||
|
className="flex cursor-default select-none items-center gap-2 rounded-lg px-3 py-1.5 text-sm/6 data-[focus]:bg-secondary/10"
|
||||||
|
>
|
||||||
|
{getDisplayValue(option)}
|
||||||
|
</ComboboxOption>
|
||||||
|
))}
|
||||||
|
</ComboboxOptions>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterField;
|
123
components/filters/filters-list.tsx
Normal file
123
components/filters/filters-list.tsx
Normal file
@ -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<Metaobject | null>(
|
||||||
|
(partType && years.find((y) => y.id === searchParams.get(YEAR_FILTER_ID))) || null
|
||||||
|
);
|
||||||
|
const [make, setMake] = useState<Metaobject | null>(
|
||||||
|
(year && makes.find((make) => make.id === searchParams.get(MAKE_FILTER_ID))) || null
|
||||||
|
);
|
||||||
|
const [model, setModel] = useState<Metaobject | null>(
|
||||||
|
(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 (
|
||||||
|
<div className="flex grow flex-col items-center gap-3 md:flex-row">
|
||||||
|
<FilterField
|
||||||
|
label="Part Type"
|
||||||
|
onChange={onChangePartType}
|
||||||
|
selectedValue={partType}
|
||||||
|
options={PART_TYPES}
|
||||||
|
getId={(option) => option.value}
|
||||||
|
displayKey="label"
|
||||||
|
/>
|
||||||
|
<FilterField
|
||||||
|
label="Make"
|
||||||
|
onChange={onChangeMake}
|
||||||
|
selectedValue={make}
|
||||||
|
options={makes}
|
||||||
|
getId={(option) => option.id}
|
||||||
|
disabled={!partType}
|
||||||
|
/>
|
||||||
|
<FilterField
|
||||||
|
label="Model"
|
||||||
|
onChange={onChangeModel}
|
||||||
|
selectedValue={model}
|
||||||
|
options={modelOptions}
|
||||||
|
getId={(option) => option.id}
|
||||||
|
disabled={!make}
|
||||||
|
/>
|
||||||
|
<FilterField
|
||||||
|
label="Year"
|
||||||
|
onChange={onChangeYear}
|
||||||
|
selectedValue={year}
|
||||||
|
options={yearOptions}
|
||||||
|
getId={(option) => option.id}
|
||||||
|
disabled={!model || !make}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={onSearch}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full rounded bg-secondary px-4 py-1.5 text-sm font-medium text-white data-[disabled]:cursor-not-allowed data-[hover]:bg-secondary/85 data-[disabled]:opacity-50 md:w-auto"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FiltersList;
|
43
components/filters/index.tsx
Normal file
43
components/filters/index.tsx
Normal file
@ -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 (
|
||||||
|
<div className="rounded border bg-white px-6 pb-5 pt-4">
|
||||||
|
<h5 className="mb-3 text-xl font-semibold leading-tight tracking-tight text-neutral-700">
|
||||||
|
Find Your Car Part
|
||||||
|
</h5>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<YMMFiltersContainer>
|
||||||
|
<FiltersList years={years} makes={makes} models={models} menu={menu} />
|
||||||
|
</YMMFiltersContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const YMMFiltersPlaceholder = () => {
|
||||||
|
return (
|
||||||
|
<YMMFiltersContainer>
|
||||||
|
<div className="flex grow animate-pulse flex-col items-center gap-3 md:flex-row">
|
||||||
|
<div className="h-9 w-full rounded bg-gray-100" />
|
||||||
|
<div className="h-9 w-full rounded bg-gray-100" />
|
||||||
|
<div className="h-9 w-full rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
</YMMFiltersContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default YMMFilters;
|
@ -20,6 +20,11 @@ export const sorting: SortFilterItem[] = [
|
|||||||
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
|
{ 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 = {
|
export const TAGS = {
|
||||||
collections: 'collections',
|
collections: 'collections',
|
||||||
products: 'products',
|
products: 'products',
|
||||||
@ -35,5 +40,8 @@ export const CORE_VARIANT_ID_KEY = 'coreVariantId';
|
|||||||
|
|
||||||
export const AVAILABILITY_FILTER_ID = 'filter.v.availability';
|
export const AVAILABILITY_FILTER_ID = 'filter.v.availability';
|
||||||
export const PRICE_FILTER_ID = 'filter.v.price';
|
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 PRODUCT_METAFIELD_PREFIX = 'filter.p.m';
|
||||||
export const VARIANT_METAFIELD_PREFIX = 'filter.v.m';
|
export const VARIANT_METAFIELD_PREFIX = 'filter.v.m';
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
AVAILABILITY_FILTER_ID,
|
AVAILABILITY_FILTER_ID,
|
||||||
HIDDEN_PRODUCT_TAG,
|
HIDDEN_PRODUCT_TAG,
|
||||||
|
MAKE_FILTER_ID,
|
||||||
|
MODEL_FILTER_ID,
|
||||||
PRICE_FILTER_ID,
|
PRICE_FILTER_ID,
|
||||||
PRODUCT_METAFIELD_PREFIX,
|
PRODUCT_METAFIELD_PREFIX,
|
||||||
SHOPIFY_GRAPHQL_API_ENDPOINT,
|
SHOPIFY_GRAPHQL_API_ENDPOINT,
|
||||||
TAGS,
|
TAGS,
|
||||||
VARIANT_METAFIELD_PREFIX
|
VARIANT_METAFIELD_PREFIX,
|
||||||
|
YEAR_FILTER_ID
|
||||||
} from 'lib/constants';
|
} from 'lib/constants';
|
||||||
import { isShopifyError } from 'lib/type-guards';
|
import { isShopifyError } from 'lib/type-guards';
|
||||||
import { ensureStartsWith, normalizeUrl, parseMetaFieldValue } from 'lib/utils';
|
import { ensureStartsWith, normalizeUrl, parseMetaFieldValue } from 'lib/utils';
|
||||||
@ -25,6 +28,7 @@ import {
|
|||||||
getCollectionsQuery
|
getCollectionsQuery
|
||||||
} from './queries/collection';
|
} from './queries/collection';
|
||||||
import { getMenuQuery } from './queries/menu';
|
import { getMenuQuery } from './queries/menu';
|
||||||
|
import { getMetaobjectsQuery } from './queries/metaobject';
|
||||||
import { getPageQuery, getPagesQuery } from './queries/page';
|
import { getPageQuery, getPagesQuery } from './queries/page';
|
||||||
import {
|
import {
|
||||||
getProductQuery,
|
getProductQuery,
|
||||||
@ -38,6 +42,7 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Image,
|
Image,
|
||||||
Menu,
|
Menu,
|
||||||
|
Metaobject,
|
||||||
Money,
|
Money,
|
||||||
Page,
|
Page,
|
||||||
PageInfo,
|
PageInfo,
|
||||||
@ -53,6 +58,8 @@ import {
|
|||||||
ShopifyCreateCartOperation,
|
ShopifyCreateCartOperation,
|
||||||
ShopifyFilter,
|
ShopifyFilter,
|
||||||
ShopifyMenuOperation,
|
ShopifyMenuOperation,
|
||||||
|
ShopifyMetaobject,
|
||||||
|
ShopifyMetaobjectsOperation,
|
||||||
ShopifyPageOperation,
|
ShopifyPageOperation,
|
||||||
ShopifyPagesOperation,
|
ShopifyPagesOperation,
|
||||||
ShopifyProduct,
|
ShopifyProduct,
|
||||||
@ -181,7 +188,10 @@ const reshapeCollections = (collections: ShopifyCollection[]) => {
|
|||||||
|
|
||||||
const reshapeFilters = (filters: ShopifyFilter[]): Filter[] => {
|
const reshapeFilters = (filters: ShopifyFilter[]): Filter[] => {
|
||||||
const reshapedFilters = [];
|
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
|
const values = filter.values
|
||||||
.map((valueItem) => {
|
.map((valueItem) => {
|
||||||
if (filter.id === AVAILABILITY_FILTER_ID) {
|
if (filter.id === AVAILABILITY_FILTER_ID) {
|
||||||
@ -222,6 +232,29 @@ const reshapeFilters = (filters: ShopifyFilter[]): Filter[] => {
|
|||||||
return reshapedFilters;
|
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<Image>, productTitle: string) => {
|
const reshapeImages = (images: Connection<Image>, productTitle: string) => {
|
||||||
const flattened = removeEdgesAndNodes(images);
|
const flattened = removeEdgesAndNodes(images);
|
||||||
|
|
||||||
@ -447,6 +480,16 @@ export async function getMenu(handle: string): Promise<Menu[]> {
|
|||||||
return formatMenuItems(res.body?.data?.menu?.items);
|
return formatMenuItems(res.body?.data?.menu?.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMetaobjects(type: string) {
|
||||||
|
const res = await shopifyFetch<ShopifyMetaobjectsOperation>({
|
||||||
|
query: getMetaobjectsQuery,
|
||||||
|
tags: [TAGS.collections, TAGS.products],
|
||||||
|
variables: { type }
|
||||||
|
});
|
||||||
|
|
||||||
|
return reshapeMetaobjects(removeEdgesAndNodes(res.body.data.metaobjects));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPage(handle: string): Promise<Page> {
|
export async function getPage(handle: string): Promise<Page> {
|
||||||
const res = await shopifyFetch<ShopifyPageOperation>({
|
const res = await shopifyFetch<ShopifyPageOperation>({
|
||||||
query: getPageQuery,
|
query: getPageQuery,
|
||||||
|
20
lib/shopify/queries/metaobject.ts
Normal file
20
lib/shopify/queries/metaobject.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
@ -62,6 +62,22 @@ export type Page = {
|
|||||||
updatedAt: string;
|
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<ShopifyProduct, 'variants' | 'images'> & {
|
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
|
||||||
variants: ProductVariant[];
|
variants: ProductVariant[];
|
||||||
images: Image[];
|
images: Image[];
|
||||||
@ -269,6 +285,11 @@ export type ShopifyPageOperation = {
|
|||||||
variables: { handle: string };
|
variables: { handle: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShopifyMetaobjectsOperation = {
|
||||||
|
data: { metaobjects: Connection<ShopifyMetaobject> };
|
||||||
|
variables: { type: string };
|
||||||
|
};
|
||||||
|
|
||||||
export type ShopifyPagesOperation = {
|
export type ShopifyPagesOperation = {
|
||||||
data: {
|
data: {
|
||||||
pages: Connection<Page>;
|
pages: Connection<Page>;
|
||||||
|
16
lib/utils.ts
16
lib/utils.ts
@ -1,6 +1,7 @@
|
|||||||
import clsx, { ClassValue } from 'clsx';
|
import clsx, { ClassValue } from 'clsx';
|
||||||
import { ReadonlyURLSearchParams } from 'next/navigation';
|
import { ReadonlyURLSearchParams } from 'next/navigation';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import { Menu } from './shopify/types';
|
||||||
|
|
||||||
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
|
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
|
||||||
const paramsString = params.toString();
|
const paramsString = params.toString();
|
||||||
@ -55,3 +56,18 @@ export const parseMetaFieldValue = <T>(field: { value: string } | null): T | nul
|
|||||||
return null;
|
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;
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user