Building A Store with Next.js + Shopify - PART 4: Search and Filter Products in Shopify
Phew!
The last section was quite the work. But I am glad you got this far. Good job!
This section will focus on writing a search and filter query on your Next.js app server and then using some tweaks on the filter to get a more specific search result.
But before that, you can search and filter effectively, you need to make some tweaks in your Shopify store. Follow the procedure below:
Head back to your Shopify admin page.
In your app and sales section, install Search and Filter.
Grant the app access to your store.
On the next page that appears, go to Create Filters.
Select price and all other values under options and make them filterable.
All the values under options are only visible if your products have variants
In the end, you’ll have something like this in your filters page.
What this means is. For the products in your Shopify store, you can only filter based on the following:
Price
Variant options e.g., Size, Color, Material
and others
So for every single query search, filter keys are fetched along side the products returned. This means that only when search query(The word being searched) is changed, do the filter get refreshed to match all the products in that result. And not when the user tries to filter the search result.
This means that we only need just one query that searches for products on query change and also on filter modification.
But for now, you are going to focus on just searching. Here is the query for just searching.
Paste this code in your <project>/app/api/query.ts
file.
export const SEARCH_PRODUCTS = `
query SearchProducts($query: String!, $first: Int, $after: String) {
search(query: $query, first: $first, after: $after, types: PRODUCT) {
totalCount
edges {
node {
... on Product {
id
title
handle
featuredImage {
url
}
priceRange {
minVariantPrice {
amount
}
}
compareAtPriceRange {
maxVariantPrice {
amount
}
}
options {
name
values
}
collections(first: 1) {
nodes {
handle
title
}
}
}
}
cursor
}
pageInfo {
hasNextPage
}
}
}
`
Next, the api handler for the query above.
Paste this code in your <project>/app/api/search/route.ts
file.
import { SEARCH_PRODUCTS } from '@/app/api/query'
import { MiniProductQueryResult } from '@/app/api/types'
import { cleanMiniProduct } from '@/app/api/utils'
import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'
const LIMIT = 4
export async function POST(Request: NextRequest) {
const searchParams = Request.nextUrl.searchParams
const query = searchParams.get('query')
const after = searchParams.get('cursor')
const variables: { first: number; query: string | null; after?: string } = {
first: LIMIT,
query,
}
if (after) variables['after'] = after
const { status, body } = await shopifyFetch({
query: SEARCH_PRODUCTS,
variables,
})
if (status === 200) {
const results = body.data?.search.edges
const pageInfo = body.data?.search.pageInfo
const len = results.length
const total = body.data?.search.totalCount
const cleanedResults = results.map(
({ node }: { node: MiniProductQueryResult }) => cleanMiniProduct(node)
)
return Response.json({
status,
body: {
results: cleanedResults,
total,
pageInfo: {
...pageInfo,
after: results[len - 1]?.cursor,
},
},
})
} else
return Response.json({ status: 500, message: 'Error receiving data..' })
}
We are done with the server section for the moment. Let's move to the client-side.
We need a search page.
Paste this code in your <project>/app/search/page.tsx
file.
'use client'
import {
PiShoppingCartThin,
PiHeartStraightThin,
PiCaretRightThin,
PiArrowDownThin,
PiArrowUpThin,
} from 'react-icons/pi'
import { Button, Text } from '@/app/components/elements'
import Card from '@/app/components/product/card'
import DropDown, { DropItem } from '@/app/components/dropdown'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { MiniProduct } from '@/lib/product'
import {
sortByDateAsc,
sortByDateDesc,
sortByNameAsc,
sortByNameDesc,
sortByPriceAsc,
sortByPriceDesc,
} from '@/lib/sort'
export default function Search() {
const searchParams = useSearchParams()
const query = searchParams.get('q')
const [products, setProducts] = useState<MiniProduct[]>([])
const [loading, setLoading] = useState(true)
const [afterCursor, setAfterCursor] = useState(null)
const [hasError, setHasError] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [total, setTotal] = useState(0)
const [sort, setSort] = useState('Name (asc)')
const sortProducts = (products: MiniProduct[], sortType: string) => {
switch (sortType) {
case 'Name (asc)':
setProducts(sortByNameAsc(products))
break
case 'Name (desc)':
setProducts(sortByNameDesc(products))
break
case 'Date (asc)':
setProducts(sortByDateAsc(products))
break
case 'Date (desc)':
setProducts(sortByDateDesc(products))
break
case 'Price (asc)':
setProducts(sortByPriceAsc(products))
break
case 'Price (desc)':
setProducts(sortByPriceDesc(products))
break
default:
setProducts(sortByNameAsc(products))
break
}
}
const load = () => {
setLoading(true)
setHasError(false)
setHasMore(false)
setTotal(0)
const cursor = afterCursor ?? ''
fetch(`/api/search?query=${query}&cursor=${cursor}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) => {
const results = data.body?.results
const newProducts =
products.length > 0 ? [...products, ...results] : results
sortProducts(newProducts, sort)
setHasMore(data.body?.pageInfo?.hasNextPage)
setAfterCursor(data.body?.pageInfo?.after)
setTotal(data.body?.total ?? 0)
})
.catch(() => setHasError(true))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [])
useEffect(() => {
sortProducts(products, sort)
}, [sort])
return (
<div className='px-7 max-w-[120rem] mx-auto'>
<div className='flex justify-between items-center gap-10 py-4'>
<div className=''>
<Text size='xl'>Belsac</Text>
</div>
<div className='flex justify-center items-stretch w-[40%]'>
<input
placeholder='Search bags'
className='grow self-stretch p-4 text-xl font-light border border-black/90 focus:outline-none placeholder:text-black/90 placeholder:text-xl'
/>
<Button onClick={() => console.log('Search button clicked')}>
Search
</Button>
</div>
<div className='flex justify-end items-center gap-10'>
<PiHeartStraightThin className='text-4xl' />
<PiShoppingCartThin className='text-4xl' />
</div>
</div>
<div className='py-4 mt-12'>
<div className='flex justify-between items-center gap-10'>
{loading ? (
<Text size='lg'>···</Text>
) : (
<Text size='lg'>
{`${total} result${total > 1 ? 's' : ''} for "${query ?? ''}"`}
</Text>
)}
<DropDown title='Sort by' initialActive='Name (asc)'>
<DropItem value='Name (asc)' onClick={() => setSort('Name (asc)')}>
<span className='flex justify-center items-center gap-0'>
<PiArrowUpThin className='text-4xl' />
Name
</span>
</DropItem>
<DropItem
value='Name (desc)'
onClick={() => setSort('Name (desc)')}
>
<span className='flex justify-center items-center gap-0'>
<PiArrowDownThin className='text-4xl' />
Name
</span>
</DropItem>
<DropItem value='Date (asc)' onClick={() => setSort('Date (asc)')}>
<span className='flex justify-center items-center gap-0'>
<PiArrowUpThin className='text-4xl' />
Date
</span>
</DropItem>
<DropItem
value='Date (desc)'
onClick={() => setSort('Date (desc)')}
>
<span className='flex justify-center items-center gap-0'>
<PiArrowDownThin className='text-4xl' />
Date
</span>
</DropItem>
<DropItem
value='Price (ascending)'
onClick={() => setSort('Price (asc)')}
>
<span className='flex justify-center items-center gap-0'>
<PiArrowUpThin className='text-4xl' />
Price
</span>
</DropItem>
<DropItem
value='Price (descending)'
onClick={() => setSort('Price (desc)')}
>
<span className='flex justify-center items-center gap-0'>
<PiArrowDownThin className='text-4xl' />
Price
</span>
</DropItem>
</DropDown>
</div>
<div className='grid grid-cols-4 place-content-between items-stretch gap-16 mt-8'>
{loading ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>Loading...</Text>
</div>
) : hasError ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>Something went wrong.</Text>
</div>
) : products.length < 1 ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>No products found. Search something else.</Text>
</div>
) : (
products.map((product) => (
<Card key={product.id} product={product} full />
))
)}
<div className='col-span-full flex justify-center items-center gap-8'>
{hasMore && (
<Button onClick={() => load()} outline>
<span className='flex justify-center items-center gap-4'>
More
<PiCaretRightThin className='text-4xl' />
</span>
</Button>
)}
</div>
</div>
</div>
</div>
)
}
Now, navigate to the search page with a query (q=<something>) in your browser. You should see a page similar to the home page but with only products matching the search query.
Before we move to the filter section. I want to point out some ways we can optimize and refactor the codes above.
Let's start with the header. The same header has appeared in two pages. Don't you think it's time to make it a component. Following the D.R.Y(Don't Repeat Yourself) rule of programming.
Let's fix that, shall we.
Paste this code in your <project>/app/components/header.tsx
file.
import { PiHeartStraightThin, PiShoppingCartThin } from 'react-icons/pi'
import { Button, Text } from './elements'
export default function Header() {
return (
<div className='flex justify-between items-center gap-10 py-4'>
<div className=''>
<Text size='xl'>Belsac</Text>
</div>
<div className='flex justify-center items-stretch w-[40%]'>
<input
placeholder='Search bags'
className='grow self-stretch p-4 text-xl font-light border border-black/90 focus:outline-none placeholder:text-black/90 placeholder:text-xl'
/>
<Button onClick={() => console.log('Search button clicked')}>
Search
</Button>
</div>
<div className='flex justify-end items-center gap-10'>
<PiHeartStraightThin className='text-4xl' />
<PiShoppingCartThin className='text-4xl' />
</div>
</div>
)
}
Also, the sort functions and components that cluster our search and home page components.
Let's do something about them. Here's the plan.
Move all the sort functions to a hook called useSort and all others to a component called Sort.
Paste this code in your <project>/app/hooks/usesort.tsx
file.
import { useEffect, useState } from 'react'
import type { MiniProduct } from '@/lib/product'
import {
sortByDateAsc,
sortByDateDesc,
sortByNameAsc,
sortByNameDesc,
sortByPriceAsc,
sortByPriceDesc,
} from '@/lib/sort'
export default function useSort() {
const [products, setProducts] = useState<MiniProduct[]>([])
const [sort, setSort] = useState('Name (asc)')
const sortProducts = (unsortedProducts: MiniProduct[], sortType: string) => {
switch (sortType) {
case 'Name (asc)':
setProducts(sortByNameAsc(unsortedProducts))
break
case 'Name (desc)':
setProducts(sortByNameDesc(unsortedProducts))
break
case 'Date (asc)':
setProducts(sortByDateAsc(unsortedProducts))
break
case 'Date (desc)':
setProducts(sortByDateDesc(unsortedProducts))
break
case 'Price (asc)':
setProducts(sortByPriceAsc(unsortedProducts))
break
case 'Price (desc)':
setProducts(sortByPriceDesc(unsortedProducts))
break
default:
setProducts(sortByNameAsc(unsortedProducts))
break
}
}
useEffect(() => {
sortProducts(products, sort)
}, [sort])
return { products, sort, setSort, sortProducts }
}
Paste this code in your <project>/app/components/sort.tsx
file.
import { PiArrowDownThin, PiArrowUpThin } from 'react-icons/pi'
import DropDown, { DropItem } from './dropdown'
export default function Sort({ setSort }: { setSort: (sort: string) => void }) {
return (
<DropDown title='Sort by' initialActive='Name (asc)'>
<DropItem value='Name (asc)' onClick={() => setSort('Name (asc)')}>
<span className='flex justify-center items-center gap-0'>
<PiArrowUpThin className='text-4xl' />
Name
</span>
</DropItem>
<DropItem value='Name (desc)' onClick={() => setSort('Name (desc)')}>
<span className='flex justify-center items-center gap-0'>
<PiArrowDownThin className='text-4xl' />
Name
</span>
</DropItem>
<DropItem value='Date (asc)' onClick={() => setSort('Date (asc)')}>
<span className='flex justify-center items-center gap-0'>
<PiArrowUpThin className='text-4xl' />
Date
</span>
</DropItem>
<DropItem value='Date (desc)' onClick={() => setSort('Date (desc)')}>
<span className='flex justify-center items-center gap-0'>
<PiArrowDownThin className='text-4xl' />
Date
</span>
</DropItem>
<DropItem value='Price (asc)' onClick={() => setSort('Price (asc)')}>
<span className='flex justify-center items-center gap-0'>
<PiArrowUpThin className='text-4xl' />
Price
</span>
</DropItem>
<DropItem value='Price (desc)' onClick={() => setSort('Price (desc)')}>
<span className='flex justify-center items-center gap-0'>
<PiArrowDownThin className='text-4xl' />
Price
</span>
</DropItem>
</DropDown>
)
}
Now, update your home page and search page with this component.
Paste this code in your <project>/app/page.tsx
file.
'use client'
import { PiCaretRightThin, PiCaretLeftThin } from 'react-icons/pi'
import { Button, Text } from '@/app/components/elements'
import Card from '@/app/components/product/card'
import { useEffect, useState } from 'react'
import Header from './components/header'
import useSort from './hooks/usesort'
import Sort from './components/sort'
export default function Home() {
const [loading, setLoading] = useState(false)
const [beforeCursor, setBeforeCursor] = useState(null)
const [afterCursor, setAfterCursor] = useState(null)
const [hasError, setHasError] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [hasPrev, setHasPrev] = useState(false)
const { products, sort, setSort, sortProducts } = useSort()
const load = (before?: string | null, after?: string | null) => {
setLoading(true)
setHasError(false)
setHasMore(false)
setHasPrev(false)
fetch(`/api/products?before=${before ?? before}&after=${after && after}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) => {
sortProducts(data.body?.results, sort)
setHasMore(data.body?.pageInfo?.hasNextPage)
setHasPrev(data.body?.pageInfo?.hasPreviousPage)
setBeforeCursor(data.body?.pageInfo?.before)
setAfterCursor(data.body?.pageInfo?.after)
})
.catch(() => setHasError(true))
.finally(() => setLoading(false))
}
useEffect(() => {
load(null, null)
}, [])
return (
<div className='px-7 max-w-[120rem] mx-auto'>
<Header />
<div className='py-4 mt-12'>
<div className='flex justify-between items-center gap-10'>
<Text size='lg'>Featured</Text>
<Sort setSort={setSort} />
</div>
<div className='grid grid-cols-4 place-content-between items-stretch gap-16 mt-8'>
{loading ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>Loading...</Text>
</div>
) : hasError ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>Something went wrong.</Text>
</div>
) : (
products.map((product) => (
<Card key={product.id} product={product} full />
))
)}
<div className='col-span-full flex justify-center items-center gap-8'>
{hasPrev && (
<Button onClick={() => load(beforeCursor, null)} outline>
<span className='flex justify-center items-center gap-4'>
<PiCaretLeftThin className='text-4xl' />
Prev
</span>
</Button>
)}
{hasMore && (
<Button onClick={() => load(null, afterCursor)} outline>
<span className='flex justify-center items-center gap-4'>
More
<PiCaretRightThin className='text-4xl' />
</span>
</Button>
)}
</div>
</div>
</div>
</div>
)
}
Paste this code in your <project>/app/search/page.tsx
file.
'use client'
import { PiCaretDownThin } from 'react-icons/pi'
import { Button, Text } from '@/app/components/elements'
import Card from '@/app/components/product/card'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import Header from '../components/header'
import useSort from '../hooks/usesort'
import Sort from '../components/sort'
export default function Search() {
const searchParams = useSearchParams()
const query = searchParams.get('q')
const [loading, setLoading] = useState(true)
const [afterCursor, setAfterCursor] = useState(null)
const [hasError, setHasError] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [total, setTotal] = useState(0)
const { products, sort, setSort, sortProducts } = useSort()
const load = () => {
setLoading(true)
setHasError(false)
setHasMore(false)
setTotal(0)
const cursor = afterCursor ?? ''
fetch(`/api/search?query=${query}&cursor=${cursor}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) => {
const results = data.body?.results
const newProducts =
products.length > 0 ? [...products, ...results] : results
sortProducts(newProducts, sort)
setHasMore(data.body?.pageInfo?.hasNextPage)
setAfterCursor(data.body?.pageInfo?.after)
setTotal(data.body?.total ?? 0)
})
.catch(() => setHasError(true))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [])
return (
<div className='px-7 max-w-[120rem] mx-auto'>
<Header />
<div className='py-4 mt-12'>
<div className='flex justify-between items-center gap-10'>
{loading ? (
<Text size='lg'>···</Text>
) : (
<Text size='lg'>
{`${total} result${total > 1 ? 's' : ''} for "${query ?? ''}"`}
</Text>
)}
<Sort setSort={setSort} />
</div>
<div className='grid grid-cols-4 place-content-between items-stretch gap-16 mt-8'>
{loading ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>Loading...</Text>
</div>
) : hasError ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>Something went wrong.</Text>
</div>
) : products.length < 1 ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>No products found. Search something else.</Text>
</div>
) : (
products.map((product) => (
<Card key={product.id} product={product} full />
))
)}
<div className='col-span-full flex justify-center items-center gap-8'>
{hasMore && (
<Button onClick={() => load()} outline>
<span className='flex justify-center items-center gap-4'>
More
<PiCaretDownThin className='text-4xl' />
</span>
</Button>
)}
</div>
</div>
</div>
</div>
)
}
Phew!
That's it for the searching section. Time for filtering the search results.
For this part, we would start by building the type for product gotten from searching earlier.
Append this code in your <project>/app/api/types.ts
file.
export interface SearchProductsQueryResult {
totalCount: number
edges: {
node: MiniProductQueryResult
cursor: string
}[]
pageInfo: {
hasNextPage: boolean
hasPreviousPage: boolean
}
}
We need this type to be able to extract the filter keys. Now let's write the function that extracts the filter keys knowing the nature of the search result.
Append this code in your <project>/app/api/utils.ts
file.
/**
* Extracts the filter from the query result
* @param queryResult The result of fetching the search products query.
* @returns A Filter containing keys partaining to query result.
*/
export function extractFilter(queryResult: SearchProductsQueryResult): Filter {
const { edges } = queryResult
const brands = Array()
let maxPrice = 0
let optionValues = {}
edges.forEach(({ node }) => {
const { options, collections, priceRange } = node
// Extract collections as brands
const collectionTitles = collections.nodes.map((node) => node.title)
brands.push(collectionTitles[0])
// Extract prices
const { minVariantPrice } = priceRange
maxPrice = Math.max(maxPrice, parseInt(minVariantPrice.amount))
// Extract other options
// 1. Extract all the names with no duplicates.
const optionNames = Array.from(
new Set(options.map((option) => option.name))
)
// 2. Extract all the values attached to the extracted names and place them in an object with boolean false value
// e.g. {'Color': {'blue': false, 'orange': false}, Size: {'small': false, 'medium': false}}
optionValues = Object.fromEntries(
optionNames.map((name) => [
name,
Object.fromEntries(
options
.filter((option) => option.name === name)[0]
.values.map((value) => [value, false])
),
])
)
})
return {
price: {
min: 0,
max: maxPrice,
highest: maxPrice,
},
brands: Object.fromEntries(brands.map((item) => [item, false])),
...optionValues,
}
}
Next, we plugin this function to the query api handler so we can send the filter down to the client per request.
Paste this code in your <project>/app/api/search/route.ts
file.
import { SEARCH_PRODUCTS } from '@/app/api/query'
import { MiniProductQueryResult } from '@/app/api/types'
import { cleanMiniProduct, extractFilter } from '@/app/api/utils'
import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'
const LIMIT = 4
export async function POST(Request: NextRequest) {
const searchParams = Request.nextUrl.searchParams
const query = searchParams.get('query')
const after = searchParams.get('cursor')
const variables: { first: number; query: string | null; after?: string } = {
first: LIMIT,
query,
}
if (after) variables['after'] = after
const { status, body } = await shopifyFetch({
query: SEARCH_PRODUCTS,
variables,
})
if (status === 200) {
const results = body.data?.search.edges
const pageInfo = body.data?.search.pageInfo
const len = results.length
const total = body.data?.search.totalCount
const filter = extractFilter(body.data?.search) // Plugin to search result.
const cleanedResults = results.map(
({ node }: { node: MiniProductQueryResult }) => cleanMiniProduct(node)
)
return Response.json({
status,
body: {
filter, // Send to client
results: cleanedResults,
total,
pageInfo: {
...pageInfo,
after: results[len - 1]?.cursor,
},
},
})
} else
return Response.json({ status: 500, message: 'Error receiving data..' })
}
With this, the client will also receive a filter alongside other fields in the response data object. So we have to redesign the client to be able to incoporate filter to the layout.
First, we will design the component to display and handle user settings of the filter. Let's start with the type the component will be working with.
Paste this code in your <project>/lib/filter.ts
file.
export interface Filter {
[key: string]: Price | FilterSubKey
}
type Price = {
min: number
max: number
highest: number
}
export type FilterSubKey = {
[subKey: string]: boolean
}
And now for the filter component.
Paste this code in your <project>/app/components/filter.tsx
file.
import { useState, ReactNode } from 'react'
import { BsCheckLg } from 'react-icons/bs'
import { PiCaretDownThin } from 'react-icons/pi'
import type { Filter, FilterSubKey } from '@/lib/filter'
import { Text } from './elements'
export default function FilterBar({
filter,
loadFilter,
}: {
filter: Filter
loadFilter: (newFilter: Filter) => void
}) {
const setPrice = (min: number, max: number) => {
const newFilter: Filter = {
...filter,
['price']: { highest: filter.price.highest as number, min, max },
}
loadFilter(newFilter)
}
const setFilterField = (key: string, subKey: string, value: boolean) => {
const newFilter: Filter = {
...filter,
[key]: { ...filter[key], [subKey]: value },
}
loadFilter(newFilter)
}
return (
<div className='flex flex-col w-full justify-start h-fit gap-16'>
<HR>
<Accordion title='Price' defaultOpen>
<PriceRangeButton
minPrice={0}
maxPrice={filter.price.highest as number}
onRangeClick={setPrice}
/>
</Accordion>
</HR>
{Object.keys(filter)
.filter((key) => key !== 'price')
.map((key) => (
<HR key={key}>
<Accordion title={key}>
{Object.keys(filter[key]).map((subKey) => (
<div key={subKey} className='flex flex-col gap-4'>
<KeySelector
check={(filter[key] as FilterSubKey)[subKey] as boolean}
setCheck={(value) => setFilterField(key, subKey, value)}
>
{subKey}
</KeySelector>
</div>
))}
</Accordion>
</HR>
))}
</div>
)
}
interface KeySelectorProps {
children: string
check?: boolean
setCheck?: (prev: boolean) => void
}
const KeySelector: React.FC<KeySelectorProps> = ({
check,
setCheck,
children,
}) => {
return (
<div
className='flex justify-start items-center gap-4'
onClick={() => setCheck?.(!check)}
>
<div
className={`w-[1.58rem] h-[1.58rem] rounded-md flex justify-center items-center border ${
check ? 'border-black' : 'border-black/20'
}`}
>
{check && <BsCheckLg className='text-4xl text-black' />}
</div>
<Text size='sm' faded={!check}>
{children}
</Text>
</div>
)
}
interface AccordionProps {
title: string
children: ReactNode[] | ReactNode
defaultOpen?: boolean
}
const Accordion: React.FC<AccordionProps> = ({
title,
defaultOpen,
children,
}) => {
const [open, setOpen] = useState(defaultOpen)
return (
<div className='w-full'>
<div
className='flex justify-between items-center gap-8 capitalize'
onClick={() => setOpen((prev) => !prev)}
>
<Text size='md'>{title}</Text>
<PiCaretDownThin className={`${open && 'rotate-180'} text-4xl`} />
</div>
<div
className={`transition-all duration-300 ease-in-out ${
open ? 'block max-h-full' : 'max-h-0 hidden'
} relative flex flex-col gap-3 items-start justify-start mt-3 py-5`}
>
{children}
</div>
</div>
)
}
interface HRProps {
dashed?: boolean
children?: React.ReactNode
}
const HR: React.FC<HRProps> = ({ dashed, children }) => {
return (
<div
className={`border-b ${
dashed && 'border-dashed'
} border-black/20 w-full ${children && 'pb-3'}`}
>
{children}
</div>
)
}
interface PriceRangeButtonProps {
minPrice: number
maxPrice: number
onRangeClick: (min: number, max: number) => void
}
const PriceRangeButton: React.FC<PriceRangeButtonProps> = ({
minPrice,
maxPrice,
onRangeClick,
}) => {
const [selectedRange, setSelectedRange] = useState([minPrice, maxPrice])
const handleRangeClick = (min: number, max: number) => {
setSelectedRange([min, max])
onRangeClick(min, max)
}
function createPriceRanges(numRanges = 8) {
const stepAmount = (maxPrice - minPrice) / (numRanges - 1) // Calculate step size
const ranges = []
let currentPrice = minPrice
for (let i = 0; i < numRanges; i++) {
if (currentPrice >= maxPrice) break // maxPrice is limit.
const maxRangePrice = Math.min(currentPrice + stepAmount, maxPrice) // Ensure max doesn't exceed maxPrice
ranges.push({
min: Math.ceil(currentPrice),
max: Math.ceil(maxRangePrice),
label: `$${Math.ceil(currentPrice)} - $${Math.ceil(maxRangePrice)}`, // Format label
})
currentPrice = maxRangePrice + 0.01 // Slight offset for non-overlapping ranges
}
return ranges
}
return (
<div className='flex justify-between flex-wrap gap-4'>
{createPriceRanges().map(({ label, min, max }) => (
<button
type='button'
key={label}
className={`px-4 py-2 border border-black/20 rounded-md hover:bg-gray-100 hover:text-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black ${
selectedRange[0] === min && selectedRange[1] === max
? 'bg-black text-white'
: ''
}`}
onClick={() => handleRangeClick(min, max)}
>
{label}
</button>
))}
</div>
)
}
export { KeySelector, Accordion, HR, PriceRangeButton }
Now that we have a functional filter component and a filter type. Let's modify the search page layout and include it. This way when the user receives the filter per request, it can be displayed.
A little explanation before we proceed...
Our filter object in the client is only refreshed when a new search query is searched by user. But our load function sends the same fetch request for both filter modification and page reload(query change). So, we need a way to tell the load function whether it's a filter change or a query change.
In general, let's update the search page to handle the effects of filter.
Paste this code in your <project>/app/search/page.tsx
file.
'use client'
import { PiCaretDownThin } from 'react-icons/pi'
import { Button, Text } from '@/app/components/elements'
import Card from '@/app/components/product/card'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import Header from '../components/header'
import useSort from '../hooks/usesort'
import Sort from '../components/sort'
import { Filter } from '@/lib/filter'
import FilterBar from '../components/filter'
export default function Search() {
const searchParams = useSearchParams()
const query = searchParams.get('q')
const [loading, setLoading] = useState(true)
const [afterCursor, setAfterCursor] = useState(null)
const [hasError, setHasError] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [total, setTotal] = useState(0)
const { products, sort, setSort, sortProducts } = useSort()
const [filter, setFilter] = useState<Filter>()
const load = (filterTriggered = false, newFilter = filter) => {
setLoading(true)
setHasError(false)
setHasMore(false)
setTotal(0)
const cursor = afterCursor && !filterTriggered ? afterCursor : ''
fetch(`/api/search?query=${query}&cursor=${cursor}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filter: newFilter ?? null }),
})
.then((res) => res.json())
.then((data) => {
const results = data.body?.results
const newProducts =
products.length > 0 && !filterTriggered
? [...products, ...results]
: results
sortProducts(newProducts, sort)
setHasMore(data.body?.pageInfo?.hasNextPage)
setAfterCursor(data.body?.pageInfo?.after)
setTotal(data.body?.total ?? 0)
!filterTriggered && setFilter(data.body?.filter)
})
.catch(() => setHasError(true))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [])
return (
<div className='px-7 max-w-[120rem] mx-auto'>
<Header />
<div className='py-4 mt-12'>
<div className='grid grid-cols-4 place-content-between items-stretch gap-8'>
<div className='flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
<Text size='lg'>Filter</Text>
{filter && (
<FilterBar
filter={filter}
loadFilter={(newFilter: Filter) => {
setFilter(newFilter)
load(true, newFilter)
}}
/>
)}
</div>
<div className='col-span-3 w-full'>
<div className='flex justify-between items-end gap-10 pb-8'>
{loading ? (
<Text size='lg'>···</Text>
) : (
<Text size='lg'>
{`${total} result${total > 1 ? 's' : ''} for "${
query ?? ''
}"`}
</Text>
)}
<Sort setSort={setSort} />
</div>
<div className='grid grid-cols-3 place-content-between items-stretch gap-16'>
{loading ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>Loading...</Text>
</div>
) : hasError ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>Something went wrong.</Text>
</div>
) : products.length < 1 ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>
No products found. Search something else.
</Text>
</div>
) : (
products.map((product) => (
<Card key={product.id} product={product} full />
))
)}
<div className='col-span-full flex justify-center items-center gap-8'>
{products.length > 0 && hasMore && (
<Button onClick={() => load()} outline>
<span className='flex justify-center items-center gap-4'>
More
<PiCaretDownThin className='text-4xl' />
</span>
</Button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
Right now, your search results should reload on every change in the filter keys (whenever you click any filter option). And your entire page refreshes whenever the query in the address bar changes (page reload).
Mission accomplished. Moving right along.
Next, we need a way to convert the client filter format to a format our query can understand. This way, whenever there is a filter change, a new one is parsed and sent to the query to update the products returned.
Append this code in your <project>/app/api/utils.ts
file.
/**
* Parses the filter into a format that can be used by the query
* @param filter The filter to be parsed.
* @returns An array of objects that can be used by the query.
*/
export function parseFilter(filter: Filter) {
// Parse price
const price = {
price: { min: filter.price.min as number, max: filter.price.max as number },
}
// Parse other variants
// Required format: [{variantOption: {name: 'color', value: 'natural'}}]
const variants = Object.keys(filter)
.filter((key) => key !== 'price' && key !== 'brands')
.map((key) =>
Object.fromEntries(
Object.keys(filter[key]).filter(subKey => (filter[key] as FilterSubKey)[subKey] === true).map((subKey) => [
'variantOptions',
Object.fromEntries([
['name', key],
['value', subKey],
])
])
)
).filter(obj => Object.keys(obj).length > 0)
return [price, ...variants]
}
Notice the logic key !== 'price' && key !== 'brands'
means the parse logic covers for anything not price or brands but since we already have a logic for price. We need to write a function to handle filtering of brands or collections.
This is because the Shopify filter field doesn't support collections filtering.
Append this code in your <project>/app/api/utils.ts
file.
/**
* Filter products whose collections contain at least on of the filter collections.
* @param result The result of fetching the search products query.
* @param filterCollections The collections to filter by.
* @returns The filtered products.
*/
export function filterProductByCollection(
result: { node: MiniProductQueryResult }[],
filterCollections: string[]
) {
return result.reduce((acc, { node }) => {
const {
collections: { nodes },
} = node
const isPassed = nodes.some(({ title }) =>
filterCollections.includes(title)
)
if (isPassed) return [...acc, cleanMiniProduct(node)]
return acc
}, Array())
}
Next, plug it in through the api route handler.
Paste this code in your <project>/app/api/search/route.ts
file.
import { SEARCH_PRODUCTS } from '@/app/api/query'
import { MiniProductQueryResult } from '@/app/api/types'
import {
cleanMiniProduct,
extractFilter,
filterProductByCollection,
parseFilter,
} from '@/app/api/utils'
import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'
type VariablesType = {
first: number
query: string | null
after?: string
filters?: any
}
const LIMIT = 6
export async function POST(Request: NextRequest) {
const { filter } = await Request.json()
const searchParams = Request.nextUrl.searchParams
const query = searchParams.get('query')
const after = searchParams.get('cursor')
const variables: VariablesType = {
first: LIMIT,
query,
}
if (after) variables['after'] = after
if (filter) variables['filters'] = parseFilter(filter)
const { status, body } = await shopifyFetch({
query: SEARCH_PRODUCTS,
variables,
})
if (status === 200) {
const results = body.data?.search.edges
const pageInfo = body.data?.search.pageInfo
const len = results.length
const total = body.data?.search.totalCount
const filterKeys = extractFilter(body.data?.search)
const cleanedResults = results.map(
({ node }: { node: MiniProductQueryResult }) => cleanMiniProduct(node)
)
// Filter by collections/brands
let brands = Array()
if (filter) {
brands = Object.keys(filter['brands']).filter(
(subKey) => filter['brands'][subKey] === true
)
}
const filterResults =
brands.length > 0
? filterProductByCollection(results, brands)
: cleanedResults
return Response.json({
status,
body: {
total,
filter: filterKeys,
results: filterResults,
pageInfo: {
...pageInfo,
after: results[len - 1]?.cursor,
},
},
})
} else
return Response.json({ status: 500, message: 'Error receiving data..' })
}
Lastly, let's edit the query, so it searches for products with our filter in mind.
Change your SEARCH_PRODUCTS query to the query below.
export const SEARCH_PRODUCTS = `
query SearchProducts($query: String!, $first: Int, $after: String, $filters: [ProductFilter!]) {
search(query: $query, first: $first, after: $after, types: PRODUCT, productFilters: $filters) {
totalCount
edges {
node {
... on Product {
id
title
handle
featuredImage {
url
}
priceRange {
minVariantPrice {
amount
}
}
compareAtPriceRange {
maxVariantPrice {
amount
}
}
options {
name
values
}
collections(first: 1) {
nodes {
handle
title
}
}
}
}
cursor
}
pageInfo {
hasNextPage
}
}
}
`
And we are done.
Notice that our search doesn't work by inputting text in the search bar. Let's fix that.
Paste this code in your <project>/app/components/header.tsx
file.
import { PiHeartStraightThin, PiShoppingCartThin } from 'react-icons/pi'
import { useRouter } from 'next/navigation'
import { Button, Text } from './elements'
import { useState } from 'react'
export default function Header({ defaultText }: { defaultText?: string }) {
const [searchText, setSearchText] = useState(defaultText)
const router = useRouter()
return (
<div className='flex justify-between items-center gap-10 py-4'>
<div className=''>
<Text size='xl'>Belsac</Text>
</div>
<div className='flex justify-center items-stretch w-[40%]'>
<input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder='Search bags'
className='grow self-stretch p-4 text-xl font-light border border-black/90 focus:outline-none placeholder:text-black/90 placeholder:text-xl'
/>
<Button onClick={() => router.push(`/search?q=${searchText}`)}>
Search
</Button>
</div>
<div className='flex justify-end items-center gap-10'>
<PiHeartStraightThin className='text-4xl' />
<PiShoppingCartThin className='text-4xl' />
</div>
</div>
)
}
Now, update the search page.
Paste this code in your <project>/app/search/page.tsx
file.
'use client'
import { PiCaretDownThin } from 'react-icons/pi'
import { Button, Text } from '@/app/components/elements'
import Card from '@/app/components/product/card'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import Header from '../components/header'
import useSort from '../hooks/usesort'
import Sort from '../components/sort'
import { Filter } from '@/lib/filter'
import FilterBar from '../components/filter'
export default function Search() {
const searchParams = useSearchParams()
const query = searchParams.get('q')
const [loading, setLoading] = useState(true)
const [afterCursor, setAfterCursor] = useState(null)
const [hasError, setHasError] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [total, setTotal] = useState(0)
const { products, sort, setSort, sortProducts } = useSort()
const [filter, setFilter] = useState<Filter>()
const load = (
filterTriggered?: boolean,
newFilter?: Filter,
cursor = afterCursor && !filterTriggered ? afterCursor : ''
) => {
setLoading(true)
setHasError(false)
setHasMore(false)
setTotal(0)
fetch(`/api/search?query=${query}&cursor=${cursor}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filter: newFilter ?? null }),
})
.then((res) => res.json())
.then((data) => {
const results = data.body?.results
const newProducts =
products.length > 0 && !filterTriggered && cursor === afterCursor
? [...products, ...results]
: results
sortProducts(newProducts, sort)
setHasMore(data.body?.pageInfo?.hasNextPage)
setAfterCursor(data.body?.pageInfo?.after)
setTotal(data.body?.total ?? 0)
!filterTriggered && setFilter(data.body?.filter)
})
.catch(() => setHasError(true))
.finally(() => setLoading(false))
}
useEffect(() => {
// Reload the entire page.
load(false, undefined, '')
}, [query])
return (
<div className='px-7 max-w-[120rem] mx-auto'>
<Header defaultText={query ?? undefined} />
<div className='py-4 mt-12'>
<div className='grid grid-cols-4 place-content-between items-stretch gap-8'>
<div className='flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
<Text size='lg'>Filter</Text>
{filter && (
<FilterBar
filter={filter}
loadFilter={(newFilter: Filter) => {
setFilter(newFilter)
load(true, newFilter)
}}
/>
)}
</div>
<div className='col-span-3 w-full'>
<div className='flex justify-between items-end gap-10 pb-8'>
{loading ? (
<Text size='lg'>···</Text>
) : (
<Text size='lg'>
{`${total} result${total > 1 ? 's' : ''} for "${
query ?? ''
}"`}
</Text>
)}
<Sort setSort={setSort} />
</div>
<div className='grid grid-cols-3 place-content-between items-stretch gap-16'>
{loading ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>Loading...</Text>
</div>
) : hasError ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>Something went wrong.</Text>
</div>
) : products.length < 1 ? (
<div className='w-full col-span-full flex justify-center items-center'>
<Text size='md'>
No products found. Search something else.
</Text>
</div>
) : (
products.map((product) => (
<Card key={product.id} product={product} full />
))
)}
<div className='col-span-full flex justify-center items-center gap-8'>
{products.length > 0 && hasMore && (
<Button onClick={() => load()} outline>
<span className='flex justify-center items-center gap-4'>
More
<PiCaretDownThin className='text-4xl' />
</span>
</Button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
That is it.
You have successfully add the search and filter feature to your Shopify storefront.
Congratulations on completing this part of the tutorial.
Here is the full code for this section in this GitHub repo.
That's a wrap for this part. In the next part you will learn how to create a dynamic product page that updates the page with information based on variant selected.
See you there.