Skip to content

Commit

Permalink
feat: filtering by facets
Browse files Browse the repository at this point in the history
Introduces dropdowns above the product grid to filter the results by facets defined in Vendure
  • Loading branch information
dlhck committed Jan 21, 2025
1 parent 7947026 commit 589014b
Show file tree
Hide file tree
Showing 33 changed files with 2,698 additions and 193 deletions.
65 changes: 65 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,68 @@ input,
button {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
}

@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}

@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
55 changes: 43 additions & 12 deletions app/search/[collection]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import {getActiveChannel, getCollection, getCollectionProducts} from 'lib/vendure';
import {
getActiveChannel,
getCollection,
getCollectionFacetValues,
getCollectionProducts,
getFacets
} from 'lib/vendure';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';

import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants';
import Facets from '../../../components/layout/search/facets';
import { CollectionProvider } from '@/components/layout/search/collection-context';

export async function generateMetadata(props: {
params: Promise<{ collection: string }>;
Expand All @@ -28,18 +36,41 @@ export default async function CategoryPage(props: {
const params = await props.params;
const { sort } = searchParams as { [key: string]: string };
const { sortKey, direction } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getCollectionProducts({ collection: params.collection, sortKey,direction });
const activeChannel = await getActiveChannel()
const facets = await getFacets();
const collection = await getCollection(params.collection);

const facetFilters = facets
.map((facet) => {
const valueIdsAsString = searchParams?.[facet.code] as string | undefined;
return {
or: valueIdsAsString?.split(',') ?? []
};
})
.filter((facetFilter) => facetFilter.or.length > 0);

const products = await getCollectionProducts({
collection: params.collection,
sortKey,
direction,
facetValueFilters: facetFilters
});
const activeChannel = await getActiveChannel();

return (
<section>
{products.length === 0 ? (
<p className="py-3 text-lg">{`No products found in this collection`}</p>
) : (
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<ProductGridItems currencyCode={activeChannel.defaultCurrencyCode} products={products} />
</Grid>
)}
</section>
<CollectionProvider collection={collection}>
<section>
<Facets facets={facets} collection={params.collection}></Facets>
{products.length === 0 ? (
<p className="py-3 text-lg">{`No products found in this collection`}</p>
) : (
<Grid className="mt-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<ProductGridItems
currencyCode={activeChannel.defaultCurrencyCode}
products={products}
/>
</Grid>
)}
</section>
</CollectionProvider>
);
}
2 changes: 1 addition & 1 deletion app/search/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import FilterList from 'components/layout/search/filter';
import { sorting } from 'lib/constants';
import ChildrenWrapper from './children-wrapper';

export default async function SearchLayout({ children, params }: { children: React.ReactNode }) {
export default async function SearchLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white">
Expand Down
21 changes: 21 additions & 0 deletions components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/ui-components",
"utils": "@/ui-components/lib/utils",
"ui": "@/ui-components/ui",
"lib": "@/ui-components/lib",
"hooks": "@/ui-components/hooks"
},
"iconLibrary": "lucide"
}
26 changes: 26 additions & 0 deletions components/layout/search/collection-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { createContext, useContext } from 'react';
import { CollectionFragment } from '@/lib/vendure/types';

const CollectionContext = createContext<CollectionFragment | undefined | null>(undefined);

export function CollectionProvider({
children,
collection
}: {
children: any;
collection: CollectionFragment | undefined | null;
}) {
return <CollectionContext.Provider value={collection}>{children}</CollectionContext.Provider>;
}

export const useCollection = () => {
const context = useContext(CollectionContext);

if (context === undefined) {
throw new Error('useCollection must be used within a CollectionProvider');
}

return context;
};
16 changes: 16 additions & 0 deletions components/layout/search/collections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ import FilterList from './filter';

async function CollectionList() {
const collections = await getCollections();


// Create a map of collections by their id
const collectionMap = new Map(collections.map(collection => [collection.id, collection]));

// Sort collections based on parentId
collections.sort((a, b) => {
if (a.parentId === b.id) {
return 1;
}
if (b.parentId === a.id) {
return -1;
}
return 0;
});

return <FilterList list={collections} title="Collections" />;
}

Expand Down
29 changes: 29 additions & 0 deletions components/layout/search/facets-filter/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Facet_ValueFragment, FacetFragment } from '@/lib/vendure/types';
import FacetsFilterItem from './item';

export default function FacetsFilter({
list,
collectionFacetValues
}: {
list: FacetFragment[];
collectionFacetValues: Pick<Facet_ValueFragment, 'code' | 'name' | 'facetId' | 'id'>[];
}) {
return (
<div className="flex flex-wrap justify-start gap-4 md:items-center">
{list
.filter(
(facet) =>
collectionFacetValues.findIndex((facetValue) => facetValue.facetId == facet.id) > -1
)
.map((facet) => {
return (
<FacetsFilterItem
item={facet}
key={facet.id}
collectionFacetValues={collectionFacetValues}
></FacetsFilterItem>
);
})}
</div>
);
}
55 changes: 55 additions & 0 deletions components/layout/search/facets-filter/item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client';

import { Facet_ValueFragment, FacetFragment } from '@/lib/vendure/types';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { MultiSelect } from '@/ui-components/multi-select';
import { useMemo } from 'react';

export default function FacetsFilterItem({
item,
collectionFacetValues
}: {
item: FacetFragment;
collectionFacetValues: Pick<Facet_ValueFragment, 'code' | 'name' | 'id'>[];
}) {
const searchParams = useSearchParams();
const { replace } = useRouter();
const pathname = usePathname();

function onFilterChange(group: string, value: string[]) {
const params = new URLSearchParams(searchParams);

if (value.length > 0) {
params.set(group, value.join(','));
} else {
params.delete(group);
}

replace(`${pathname}?${params.toString()}`);
}

const defaultValue = useMemo(() => {
return searchParams.get(item.code)?.split(',') ?? [];
}, [searchParams]);

return (
<div className="max-w-[50%] md:max-w-[250px] shrink-0 grow">
<h3 className="mb-2 block text-xs text-neutral-500 dark:text-neutral-400">{item.name}</h3>
<div>
<MultiSelect
defaultValue={defaultValue}
options={item.values
.filter(
(itemValue) =>
collectionFacetValues.findIndex((facetValue) => facetValue.id === itemValue.id) > -1
)
.map((itemValue) => ({
label: itemValue.name,
value: itemValue.id
}))}
onValueChange={(value) => onFilterChange(item.code, value)}
/>
</div>
</div>
);
}
37 changes: 37 additions & 0 deletions components/layout/search/facets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import clsx from 'clsx';
import { Suspense } from 'react';
import { getCollectionFacetValues, getFacets } from '@/lib/vendure';
import FacetsFilter from './facets-filter';
import { FacetFragment } from '@/lib/vendure/types';

const skeleton = 'h-10 w-full animate-pulse rounded';
const items = 'bg-neutral-400 dark:bg-neutral-700';

async function FacetsList({ collection, facets }: { collection: string; facets: FacetFragment[] }) {
const collectionFacetValues = collection ? await getCollectionFacetValues({ collection }) : [];

return <FacetsFilter list={facets} collectionFacetValues={collectionFacetValues}></FacetsFilter>;
}

export default function Facets({
collection,
facets
}: {
collection: string;
facets: FacetFragment[];
}) {
return (
<Suspense
fallback={
<div className="gap-4 hidden w-full py-4 lg:flex">
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
<div className={clsx(skeleton, items)} />
</div>
}
>
<FacetsList facets={facets} collection={collection} />
</Suspense>
);
}
4 changes: 2 additions & 2 deletions components/layout/search/filter/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
useEffect(() => {
list.forEach((listItem: ListItem) => {
if (
('path' in listItem && pathname === listItem.path) ||
('slug' in listItem && searchParams.get('sort') === listItem.slug)
'slug' in listItem &&
(searchParams.get('sort') === listItem.slug || pathname === `/search/${listItem.slug}`)
) {
setActive(listItem.name);
}
Expand Down
4 changes: 2 additions & 2 deletions components/layout/search/filter/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { SortFilterItem } from 'lib/constants';
import { Suspense } from 'react';
import FilterItemDropdown from './dropdown';
import { FilterItem } from './item';
import { CollectionFragment } from '../../../../lib/vendure/types';
import { CollectionFragment } from '@/lib/vendure/types';

export type ListItem = SortFilterItem | PathFilterItem;
export type PathFilterItem = CollectionFragment;
export type PathFilterItem = Pick<CollectionFragment, 'slug' | 'parentId' | 'name'>;

function FilterItemList({ list }: { list: ListItem[] }) {
return (
Expand Down
Loading

0 comments on commit 589014b

Please sign in to comment.