Building A Store with Next.js + Shopify - PART 3: Fetch products from Shopify and sort in Frontend

Get your fingers ready because you’re going to write lots of code in this section.

Remember the requirements:

  1. You must have completed the previous courses in this series.

But first, let’s make a plan.

We have to take a step back and plan our storefront and to do that we need a simple software architecture.

Here is the architecture of our project.

The design above is straightforward enough. The application is divided into client and server - the API (as usual).

The server converts clients' input into queries that are sent through the Shopify Storefront API your Shopify store. It also cleans up data results of these queries and sends them back to the client.

The client has just one simple job. Displays the data with a usable and accessible user interface.

As usual, we are going to begin with the client.

Add the code below to your <project>/app/components/elements.tsx file

import Link from 'next/link'

type Size = 'xl' | 'lg' | 'md' | 'sm' | 'xs'

export function Text({
  white,
  faded,
  copy,
  size,
  children,
}: {
  white?: boolean
  faded?: boolean
  copy?: boolean
  size: Size
  children: string
}) {
  const sizeStyles = `${size === 'xl' && 'text-4xl'} ${
    size === 'lg' && 'text-3xl'
  } ${size === 'md' && 'text-2xl'} ${size === 'sm' && 'text-xl'} ${
    size === 'xs' && 'text-lg'
  }`

  const isCopy = `${copy ? 'leading-snug' : 'leading-none'}`
  const isFaded = `${faded ? 'text-black/90' : 'text-black'}`
  const isWhite = `${white ? 'text-white' : 'text-black'}`

  return (
    <p className={`${isCopy} ${isFaded} ${isWhite} ${sizeStyles} font-light`}>
      {children}
    </p>
  )
}

export function TextLink({
  href,
  size,
  children,
}: {
  href: string
  size: Size
  children: string
}) {
  return (
    <Link
      href={href}
      className='text-black/90 decoration-from-font hover:underline group-hover:underline hover:underline-offset-4 group-hover:underline-offset-4 line-clamp-2'
    >
      <Text size={size} copy>
        {children}
      </Text>
    </Link>
  )
}

export function Button({
  outline,
  children,
  onClick,
}: {
  outline?: boolean
  children: React.ReactNode
  onClick: () => void
}) {
  const outlineStyles = `${outline ? 'bg-transparent' : 'bg-black/90'}`

  return (
    <button
      className={`py-5 px-10 shadow-md border border-black/90 ${outlineStyles}`}
      onClick={onClick}
    >
      {children}
    </button>
  )
}

To get started we are going to design the home page and the product card.

Before that, install this icon library. Paste this command in your project terminal.

$ npm install react-icons

Let's

The code listings below use React.js for component structuring and Tailwind CSS for styling as we did in the elements.tsx file

Add the code below to your <project>/app/components/dropdown.tsx file.

import { createContext, useContext, useState } from 'react'
import { BsCheckLg } from 'react-icons/bs'
import { PiCaretDownThin } from 'react-icons/pi'
import { Button } from './elements'

type DropContextType = {
  active: string
  setActive: (value: string) => void
  setIsOpen: (value: boolean) => void
}

const DropContext = createContext<DropContextType>({
  active: '',
  setActive: () => {
    return
  },
  setIsOpen: () => {
    return
  },
})

const useDrop = () => useContext(DropContext)

export default function DropDown({
  title,
  initialActive,
  children,
}: {
  title: string
  initialActive: string
  children: React.ReactNode | React.ReactNode[]
}) {
  const [active, setActive] = useState(initialActive)
  const [isOpen, setIsOpen] = useState(false)

  return (
    <DropContext.Provider
      value={{
        active,
        setActive: (value: string) => setActive(value),
        setIsOpen: (value: boolean) => setIsOpen(value),
      }}
    >
      {isOpen && (
        <div
          className='fixed inset-0 bg-transparent'
          onClick={() => setIsOpen(false)}
        />
      )}
      <div className='relative' aria-haspopup='menu'>
        <Button onClick={() => setIsOpen((prev) => !prev)} outline>
          <span className='flex justify-center items-center gap-4'>
            {title}
            <PiCaretDownThin className='text-4xl' />
          </span>
        </Button>
        <div
          className={`absolute top-[100%] right-0 ${
            isOpen ? 'flex' : 'hidden'
          } flex-col items-stretch`}
        >
          {children}
        </div>
      </div>
    </DropContext.Provider>
  )
}

export function DropItem({
  value,
  onClick,
  children,
}: {
  value: string
  onClick: () => void
  children: React.ReactNode
}) {
  const { active, setActive, setIsOpen } = useDrop()
  const isActive = active === value

  const outlineStyles = `${
    isActive ? 'bg-black/90 text-white' : 'bg-white text-black/90'
  }`

  return (
    <button
      className={`py-4 px-10 shadow-md border border-black/90 leading-none text-2xl font-light ${outlineStyles}`}
      onClick={() => {
        setActive(value)
        onClick()
        setIsOpen(false)
      }}
    >
      <span className='flex justify-center items-center gap-4'>
        {children}
        <BsCheckLg className='text-4xl text-white' />
      </span>
    </button>
  )
}

Add the code below to your <project>/app/page.tsx file. This will serve as your home page.

'use client'

import {
  PiShoppingCartThin,
  PiHeartStraightThin,
  PiArrowDownThin,
  PiArrowUpThin,
} from 'react-icons/pi'
import { Button, Text, TextLink } from './components/elements'
import Card from './components/product/card'
import DropDown, { DropItem } from './components/dropdown'

export default function Home() {
  return (
    <div className='px-7'>
      <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-10'>
        <div className='flex justify-between items-center gap-10'>
          <Text size='lg'>Featured</Text>
          <DropDown title='Sort by' initialActive='Name (ascending)'>
            <DropItem
              value='Name (ascending)'
              onClick={() => console.log('First dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowUpThin className='text-4xl' />
                Name
              </span>
            </DropItem>
            <DropItem
              value='Name (descending)'
              onClick={() => console.log('Second dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowDownThin className='text-4xl' />
                Name
              </span>
            </DropItem>
            <DropItem
              value='Date (ascending)'
              onClick={() => console.log('Third dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowUpThin className='text-4xl' />
                Date
              </span>
            </DropItem>
            <DropItem
              value='Date (descending)'
              onClick={() => console.log('Fourth dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowDownThin className='text-4xl' />
                Date
              </span>
            </DropItem>
            <DropItem
              value='Price (ascending)'
              onClick={() => console.log('Fifth dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowUpThin className='text-4xl' />
                Price
              </span>
            </DropItem>
            <DropItem
              value='Price (descending)'
              onClick={() => console.log('Sixth dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowDownThin className='text-4xl' />
                Price
              </span>
            </DropItem>
          </DropDown>
        </div>
        <div className='flex justify-start items-center gap-24 mt-4'>
          <Card
            product={{
              title: 'The Classic Handbag By Prada 2023',
              handle: 'the-classic-handbag-by-prada-2023',
              featuredImage:
                'https://images.selfridges.com/is/image/selfridges/R04229467_SAND_M?wid=476&hei=634&fmt=webp&qlt=80,1&bgc=F6F6F6&extend=-18,0,-18,0',
              price: '$200',
              compareAtPrice: '$300',
              collectionHandle: 'prada-handbags',
            }}
          />
          <Card
            product={{
              title: 'The Classic Handbag By Prada 2023',
              handle: 'the-classic-handbag-by-prada-2023',
              featuredImage:
                'https://images.selfridges.com/is/image/selfridges/R04229467_SAND_M?wid=476&hei=634&fmt=webp&qlt=80,1&bgc=F6F6F6&extend=-18,0,-18,0',
              price: '$200',
              compareAtPrice: '$300',
              collectionHandle: 'prada-handbags',
            }}
          />
          <Card
            product={{
              title: 'The Classic Handbag By Prada 2023',
              handle: 'the-classic-handbag-by-prada-2023',
              featuredImage:
                'https://images.selfridges.com/is/image/selfridges/R04229467_SAND_M?wid=476&hei=634&fmt=webp&qlt=80,1&bgc=F6F6F6&extend=-18,0,-18,0',
              price: '$200',
              compareAtPrice: '$300',
              collectionHandle: 'prada-handbags',
            }}
          />
          <Card
            product={{
              title: 'The Classic Handbag By Prada 2023',
              handle: 'the-classic-handbag-by-prada-2023',
              featuredImage:
                'https://images.selfridges.com/is/image/selfridges/R04229467_SAND_M?wid=476&hei=634&fmt=webp&qlt=80,1&bgc=F6F6F6&extend=-18,0,-18,0',
              price: '$200',
              compareAtPrice: '$300',
              collectionHandle: 'prada-handbags',
            }}
          />
        </div>
      </div>
    </div>
  )
}

Add the type of product card along with the price formater. Paste the code below in <project>/lib/product.ts file.

export type MiniProduct = {
  id: string
  title: string
  handle: string
  featuredImage: string
  price: number
  compareAtPrice: string
  collectionHandle: string
}

export function formatMoney(number: number, dp = 0) {
  const formatter = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    minimumFractionDigits: dp,
    maximumFractionDigits: 2,
  })

  return formatter.format(number)
}

Add the code below to your app/components/product/card/tsx file. This will serve as your product card.

import Image from 'next/image'
import { Text, TextLink } from '../elements'
import { MiniProduct } from '@/lib/product'

export default function Card({ product }: { product: MiniProduct }) {
  const {
    title,
    handle,
    featuredImage,
    price,
    compareAtPrice,
    collectionHandle,
  } = product

  return (
    <div className='shadow-lg w-1/5 border border-black/65 flex flex-col gap-0 group hover:border-black/90'>
      <Image
        src={featuredImage}
        alt={title}
        width={400}
        height={400}
        className='grow'
      />
      <div className='flex flex-col w-full gap-2 p-4'>
        <div className='flex justify-start items-end gap-3'>
          <Text size='lg'>{price}</Text>
          <span className='text-black/80 line-through'>
            <Text size='xs' faded>
              {compareAtPrice}
            </Text>
          </span>
        </div>
        <TextLink
          size='sm'
          href={`/collection/${collectionHandle}/product/${handle}`}
        >
          {title}
        </TextLink>
      </div>
    </div>
  )
}

The front end (client) is ready. Now let’s move to the backend and write our queries and API routes.

The query will be written in GraphQL.

One of the perks of using GraphQL is that you get to request exactly what you need and nothing more. This way you reduce loading time and prevent fetching lots of useless data.

Let’s start writing queries, shall we?

The query below is called RETRIEVE_PRODUCTS. This query does exactly what the name suggests. It also supports pagination to fetch both the previous limit and the next limit set of products on the running of the query. It handles pagination by using the arguments in the query (first, last, before and after).

Add the query below to your app/api/query.ts file.

export const RETRIEVE_PRODUCTS = `
query AllProducts($first: Int, $last: Int, $before: String, $after: String) {
  products(first: $first, last: $last, after: $after, before: $before) {
    edges {
      node {
        id
        title
        handle
        createdAt
        featuredImage {
          url
        }
        priceRange {
          minVariantPrice {
            amount
          }
        }
        compareAtPriceRange {
          maxVariantPrice {
            amount
          }
        }
        options {
          name
          values
        }
        collections(first: 10) {
          nodes {
            handle
            title
          }
        }
      }
      cursor
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
    }
  }
}
`

Our query above requires only the first and the cursor variables, so we will create a variables object and add the two fields with values to it.

Because we are using GraphQL we know exactly the shape of the object we will be getting as a result. And for that reason, we can create a type for it easily.

Paste the code below in your <project>/app/api/types.ts file:

export interface MiniProductQueryResult {
  id: string
  title: string
  handle: string
  totalInventory: number
  featuredImage: {
    url: string
  }
  priceRange: {
    minVariantPrice: {
      amount: string
    }
  }
  compareAtPriceRange: {
    maxVariantPrice: {
      amount: string
    }
  }
  options: {
    name: string
    values: string[]
  }[]
  collections: {
    nodes: {
      handle: string
      title: string
    }[]
  }
}

Now we will create an API route.

This will call our generic fetch function(shopify-fetch) using your query and variables object as parameters, clean up the code and return the result in a usable format to the client.

Paste the code below in your app/api/products/route file

import { RETRIEVE_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 after = searchParams.get('after')
  const before = searchParams.get('before')

  let variables

  if (before !== 'null') {
    variables = {
      last: LIMIT,
      before: before === 'null' ? null : before,
    }
  } else {
    variables = {
      first: LIMIT,
      after: after === 'null' ? null : after,
    }
  }

  const { status, body } = await shopifyFetch({
    query: RETRIEVE_PRODUCTS,
    variables,
  })

  if (status === 200) {
    const results = body.data?.products.edges
    const pageInfo = body.data?.products.pageInfo
    const len = results.length

    const cleanedResults = results.map(
      ({ node }: { node: MiniProductQueryResult }) => cleanMiniProduct(node)
    )

    return Response.json({
      status,
      body: {
        results: cleanedResults,
        pageInfo: {
          ...pageInfo,
          after: results[len - 1].cursor,
          before: results[0].cursor,
        },
      },
    })
  } else
    return Response.json({ status: 500, message: 'Error receiving data..' })
}

Just like we had two queries, we have to create two API routes to send the request

You can see the cleanMiniProductList function wrapped around the query node data returned in the code above

When you fetch the queries (you probably can’t right now, but I have), you will get a result that is so complicated that it won’t be easy to use by client components.

Because of this, I wrote a cleaner function for the query result of all mini-product queries. This way all you have to do is call it on the body object returned on calling the fetch function and you’ll have a cleaner format of the result data.

Here is the cleaner function tailored to a query.

Paste the code below in your app/api/utils.ts file

import { MiniProductQueryResult } from './types'

/**
 * Cleans up the query result of mini product
 * @param queryResult The result of fetching the mini product query.
 * @returns A cleaner version of mini product that can be used by components
 */
export function cleanMiniProduct(queryResult: MiniProductQueryResult) {
  const {
    id,
    title,
    handle,
    featuredImage,
    priceRange,
    compareAtPriceRange,
    options,
    collections,
    createdAt
  } = queryResult
  const { url } = featuredImage
  const { minVariantPrice } = priceRange
  const { maxVariantPrice } = compareAtPriceRange
  const collectionHandles = collections.nodes.map((node) => node.handle)

  let colors = Array()
  options
    .filter((option) => option.name === 'Color')
    .forEach((option) => colors.push(...option.values))

  return {
    id,
    title,
    handle,
    createdAt,
    featuredImage: url,
    price: parseInt(minVariantPrice.amount),
    compareAtPrice: parseInt(maxVariantPrice.amount),
    collectionHandle: collectionHandles[0],
  }
}

Because of the type created earlier, we can pass the query's result to the cleaner function above to destructure and create a better object format for our components on the client side.

Look at the return object of our cleaner function. See any resemblance with our product card design.

Yes!!

It contains every data we need to pass into the product card component in a clearer and more accessible way.

Let’s head back to the client to see it in operation.

Now let’s display our query results in the cards.

On the home page of your Next.js project `<project>/app/page.tsx` update the previous code to this.

'use client'

import {
  PiShoppingCartThin,
  PiHeartStraightThin,
  PiCaretRightThin,
  PiArrowDownThin,
  PiArrowUpThin,
} from 'react-icons/pi'
import { Button, Text, TextLink } from './components/elements'
import Card from './components/product/card'
import DropDown, { DropItem } from './components/dropdown'
import { useEffect, useState } from 'react'
import { MiniProduct } from '@/lib/product'

export default function Home() {
  const [products, setProducts] = useState<MiniProduct[]>([])
  const [loading, setLoading] = useState(false)
  const [cursor, setCursor] = useState(null)
  const [hasError, setHasError] = useState(false)
  const [hasMore, setHasMore] = useState(false)

  const loadMore = () => {
    setLoading(true)
    setHasError(false)

    fetch(`/api/get/product/all?cursor=${cursor}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
    })
      .then((res) => res.json())
      .then((data) => {
        setProducts(data.body?.results)
        setHasMore(data.body?.pageInfo?.hasNext)
        setCursor(data.body?.pageInfo?.cursor)
      })
      .catch(() => setHasError(true))
      .finally(() => setLoading(false))
  }

  useEffect(() => {
    setLoading(true)
    setHasError(false)

    fetch('/api/get/products', {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
    })
      .then((res) => res.json())
      .then((data) => {
        setProducts(data.body?.results)
        setHasMore(data.body?.pageInfo?.hasNext)
        setCursor(data.body?.pageInfo?.cursor)
      })
      .catch(() => setHasError(true))
      .finally(() => setLoading(false))
  }, [])

  return (
    <div className='px-7'>
      <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-10'>
        <div className='flex justify-between items-center gap-10'>
          <Text size='lg'>Featured</Text>
          <DropDown title='Sort by' initialActive='Name (ascending)'>
            <DropItem
              value='Name (ascending)'
              onClick={() => console.log('First dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowUpThin className='text-4xl' />
                Name
              </span>
            </DropItem>
            <DropItem
              value='Name (descending)'
              onClick={() => console.log('Second dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowDownThin className='text-4xl' />
                Name
              </span>
            </DropItem>
            <DropItem
              value='Date (ascending)'
              onClick={() => console.log('Second dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowUpThin className='text-4xl' />
                Date
              </span>
            </DropItem>
            <DropItem
              value='Date (descending)'
              onClick={() => console.log('Second dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowDownThin className='text-4xl' />
                Date
              </span>
            </DropItem>
            <DropItem
              value='Price (ascending)'
              onClick={() => console.log('Second dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowUpThin className='text-4xl' />
                Price
              </span>
            </DropItem>
            <DropItem
              value='Price (descending)'
              onClick={() => console.log('Second dropitem clicked')}
            >
              <span className='flex justify-center items-center gap-0'>
                <PiArrowDownThin className='text-4xl' />
                Price
              </span>
            </DropItem>
          </DropDown>
        </div>
        <div className='flex justify-start items-center gap-24 mt-4'>
          {loading ? (
            <Text size='lg'>Loading...</Text>
          ) : hasError ? (
            <Text size='lg'>Something went wrong.</Text>
          ) : (
            products.map((product) => (
              <Card key={product.id} product={product} />
            ))
          )}
          {hasMore && (
            <div className='w-full flex justify-center items-center'>
              <Button onClick={loadMore} outline>
                <span className='flex justify-center items-center gap-0'>
                  More
                  <PiCaretRightThin className='text-4xl' />
                </span>
              </Button>
            </div>
          )}
        </div>
      </div>
    </div>
  )
}

If you are familiar with react, nothing is surprising in the code above.

But if you are like me, who just find code listings confusing at a glance. Let’s go through it together.

The React component function has two main states;

  1. The Loading state.

  2. The products state and

  3. The hasError state

When your component mounts on DOM, the useEffect hook runs a fetch to the API route we created on the server. Passing the query required variables to fetch our products. And passes the returned list of products to the products state. If any error is encountered while fetching, the hasError state is set to true and the client is informed of the problem encountered.

By default, the loading state is set to true and after the fetch, it’s set to false. The empty products list is updated after the fetch with the returned products or it’s replaced by an error message if an error occurred.

With that out of the way. Let’s write the sorting algorithm for the products returned.

Here is the sorting I think users would love in a fashion bags store (Hope you agree):

  1. Sort alphabetically A – Z and Z – A.

  2. Sort by date latest – Oldest and Oldest – latest.

  3. Sort by price Most Expensive – Least Expensive and vice versa.

Below is the code for sorting products. Paste it in your <project>/lib/sort.ts file.

import { MiniProduct } from './product'

/**
 * Sorts a list of products objects based on the title field,
 * in ascending alphabetical order.
 * @param products The list of products to sort.
 * @returns The sorted list of products.
 */
export function sortByNameAsc(products: MiniProduct[]) {
  return products.slice().sort((a, b) => {
    return a.title.localeCompare(b.title)
  })
}

/**
 * Sorts a list of products objects based on the title field,
 * in descending alphabetical order.
 * @param products The list of products to sort.
 * @returns The sorted list of products.
 */
export function sortByNameDesc(products: MiniProduct[]) {
  return products.slice().sort((a, b) => {
    return b.title.localeCompare(a.title)
  })
}

/**
 * Sorts a list of products objects based on the date field,
 * in ascending order.
 * @param products The list of products to sort.
 * @returns The sorted list of products.
 */
export function sortByDateAsc(products: MiniProduct[]) {
  return products.slice().sort((a, b) => {
    const aDate = new Date(a.createdAt)
    const bDate = new Date(b.createdAt)
    return aDate.getMilliseconds() - bDate.getMilliseconds()
  })
}

/**
 * Sorts a list of products objects based on the date field,
 * in descending order.
 * @param products The list of products to sort.
 * @returns The sorted list of products.
 */
export function sortByDateDesc(products: MiniProduct[]) {
  return products.slice().sort((a, b) => {
    const aDate = new Date(a.createdAt)
    const bDate = new Date(b.createdAt)
    return bDate.getMilliseconds() - aDate.getMilliseconds()
  })
}

/**
 * Sorts a list of products objects based on the price field,
 * in ascending order.
 * @param products The list of products to sort.
 * @returns The sorted list of products.
 */
export function sortByPriceAsc(products: MiniProduct[]) {
  return products.slice().sort((a, b) => {
    return a.price - b.price
  })
}

/**
 * Sorts a list of products objects based on the price field,
 * in descending order.
 * @param products The list of products to sort.
 * @returns The sorted list of products.
 */
export function sortByPriceDesc(products: MiniProduct[]) {
  return products.slice().sort((a, b) => {
    return b.price - a.price
  })
}

With the sorting functions created. We need to edit the home page app/page.tsx, so it integrates it.

Edit your home page <project>/app/page.tsx, with this updated code.

'use client'

import {
  PiShoppingCartThin,
  PiHeartStraightThin,
  PiCaretRightThin,
  PiCaretLeftThin,
  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 { MiniProduct } from '@/lib/product'
import {
  sortByDateAsc,
  sortByDateDesc,
  sortByNameAsc,
  sortByNameDesc,
  sortByPriceAsc,
  sortByPriceDesc,
} from '@/lib/sort'

export default function Home() {
  const [products, setProducts] = useState<MiniProduct[]>([])
  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 [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 = (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)
  }, [])

  useEffect(() => {
    sortProducts(products, sort)
  }, [sort])

  return (
    <div className='px-7'>
      <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'>
          <Text size='lg'>Featured</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.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>
  )
}

And that is it. You have just created the home page of your online store containing all featured elements and a way to sort them.

Here is the full code for this section in this GitHub repo.

That’s a wrap for this section. In the next section, you will write the search page and filter the results based on user clicks on the client.

See you there.