diff --git a/apps/web/package.json b/apps/web/package.json index e961d5e..712966c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,6 +3,10 @@ "version": "0.1.0", "private": true, "type": "module", + "prisma": { + "schema": "apps/web/prisma/schema.prisma", + "seed": "node apps/web/prisma/seed.js" + }, "scripts": { "build": "next build", "db:generate": "prisma migrate dev", diff --git a/apps/web/src/app/_components/features/FilterModal.tsx b/apps/web/src/app/_components/features/FilterModal.tsx new file mode 100644 index 0000000..c506207 --- /dev/null +++ b/apps/web/src/app/_components/features/FilterModal.tsx @@ -0,0 +1,183 @@ +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { motion } from "framer-motion"; +import { useAtom } from "jotai"; +import { useState } from "react"; +import { + isLoadingAtom, + quantityOfProducts, + searchResultsAtom, +} from "~/atoms/productAtom"; +import { api } from "~/trpc/react"; + +interface FilterModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function FilterModal({ isOpen, onClose }: FilterModalProps) { + const [selectedStrength, setSelectedStrength] = useState(""); + const [selectedRegion, setSelectedRegion] = useState(""); + const [selectedOrder, setSelectedOrder] = useState(""); + const [, setSearchResults] = useAtom(searchResultsAtom); + const [, setQuantityProducts] = useAtom(quantityOfProducts); + const [, setIsLoading] = useAtom(isLoadingAtom); + + const { refetch } = api.product.filterProducts.useQuery( + { + strength: selectedStrength || undefined, + region: selectedRegion || undefined, + orderBy: selectedOrder || undefined, + }, + { + enabled: false, + }, + ); + + const handleApply = async () => { + setIsLoading(true); + try { + const { data } = await refetch(); + + if (data?.productsFound) { + setSearchResults(data.productsFound); + setQuantityProducts(data.productsFound.length); + } else { + setSearchResults([]); + setQuantityProducts(0); + } + } catch (error) { + setSearchResults([]); + setQuantityProducts(0); + } finally { + setIsLoading(false); + onClose(); + } + }; + + const handleClear = () => { + setSelectedStrength(""); + setSelectedRegion(""); + setSelectedOrder(""); + setSearchResults([]); + setQuantityProducts(0); + }; + + if (!isOpen) return null; + + const hasActiveFilters = selectedStrength || selectedRegion || selectedOrder; + + return ( + + e.stopPropagation()} + > +
+
+

Filter by

+ {hasActiveFilters && ( + + )} +
+ +
+ +
+
+

Roast level

+
+ {["Light", "Medium", "Strong"].map((strength) => ( + + ))} +
+
+ +
+

Region

+
+ {["Region A", "Region B", "Region C"].map((region) => ( + + ))} +
+
+ +
+

Order by

+
+ {["Highest price", "Lowest price"].map((order) => ( + + ))} +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/_components/features/SearchBar.tsx b/apps/web/src/app/_components/features/SearchBar.tsx index ed13799..35639fd 100644 --- a/apps/web/src/app/_components/features/SearchBar.tsx +++ b/apps/web/src/app/_components/features/SearchBar.tsx @@ -1,7 +1,8 @@ +import { FunnelIcon } from "@heroicons/react/24/outline"; import { zodResolver } from "@hookform/resolvers/zod"; import InputField from "@repo/ui/form/inputField"; import { useAtom } from "jotai"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { @@ -11,6 +12,7 @@ import { searchResultsAtom, } from "~/atoms/productAtom"; import { api } from "~/trpc/react"; +import FilterModal from "./FilterModal"; const searchSchema = z.object({ region: z.string(), @@ -23,6 +25,7 @@ export default function SearchBar() { const [, setSearchResults] = useAtom(searchResultsAtom); const [, setQuantityProducts] = useAtom(quantityOfProducts); const [, setIsLoading] = useAtom(isLoadingAtom); + const [isFilterOpen, setIsFilterOpen] = useState(false); const { control } = useForm({ resolver: zodResolver(searchSchema), @@ -56,14 +59,29 @@ export default function SearchBar() { }; return ( -
- - name="region" - control={control} - label="" - placeholder="Search for your coffee" - onChange={(value: string) => handleInputChange(value)} + <> +
+ + name="region" + control={control} + label="" + placeholder="Search for your coffee" + onChange={(value: string) => handleInputChange(value)} + className="gap-0 mr-3 w-3/4" + showSearchIcon={true} + /> + +
+ setIsFilterOpen(false)} /> -
+ ); } diff --git a/apps/web/src/server/api/routers/product.ts b/apps/web/src/server/api/routers/product.ts index 19d5574..4ea0544 100755 --- a/apps/web/src/server/api/routers/product.ts +++ b/apps/web/src/server/api/routers/product.ts @@ -3,19 +3,24 @@ import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { mockedProducts } from "./mockProducts"; // TODO: Replace mockedProducts with real data fetched from the blockchain in the near future. +const normalizeText = (text: string): string => { + return text + .toLowerCase() + .normalize("NFD") + .replace(/[^\w\s]/g, ""); +}; export const productRouter = createTRPCRouter({ getProducts: publicProcedure .input( z.object({ limit: z.number().min(1), - cursor: z.number().optional(), // Pagination cursor + cursor: z.number().optional(), }), ) .query(({ input }) => { const { limit, cursor } = input; - // Find the starting index based on the cursor let startIndex = 0; if (cursor) { const index = mockedProducts.findIndex( @@ -24,10 +29,7 @@ export const productRouter = createTRPCRouter({ startIndex = index >= 0 ? index + 1 : 0; } - // Get the slice of products for the current page const products = mockedProducts.slice(startIndex, startIndex + limit); - - // Determine the next cursor const nextCursor = products.length === limit ? products[products.length - 1]?.id : null; @@ -36,6 +38,7 @@ export const productRouter = createTRPCRouter({ nextCursor, }; }), + searchProductCatalog: publicProcedure .input( z.object({ @@ -45,12 +48,62 @@ export const productRouter = createTRPCRouter({ .query(({ input }) => { const { region } = input; - const productsFound = mockedProducts.filter( - (product) => product.region === region, - ); + const normalizedSearchTerm = normalizeText(region); + + const productsFound = mockedProducts.filter((product) => { + const normalizedRegion = normalizeText(product.region); + const normalizedName = normalizeText(product.name); + const normalizedFarmName = normalizeText(product.farmName); + + return ( + normalizedRegion.includes(normalizedSearchTerm) || + normalizedName.includes(normalizedSearchTerm) || + normalizedFarmName.includes(normalizedSearchTerm) + ); + }); return { productsFound, }; }), + + filterProducts: publicProcedure + .input( + z.object({ + strength: z.string().optional(), + region: z.string().optional(), + orderBy: z.string().optional(), + }), + ) + .query(({ input }) => { + const { strength, region, orderBy } = input; + let filteredProducts = [...mockedProducts]; + + if (strength) { + const normalizedStrength = normalizeText(strength); + filteredProducts = filteredProducts.filter( + (product) => normalizeText(product.strength) === normalizedStrength, + ); + } + + if (region) { + const normalizedRegion = normalizeText(region); + filteredProducts = filteredProducts.filter( + (product) => normalizeText(product.region) === normalizedRegion, + ); + } + + if (orderBy) { + filteredProducts.sort((a, b) => { + if (orderBy === "Highest price") { + return b.price - a.price; + } + return a.price - b.price; + }); + } + + return { + productsFound: filteredProducts, + }; + }), }); diff --git a/bun.lockb b/bun.lockb index 30bd03f..c70b302 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/ui/src/form/inputField.tsx b/packages/ui/src/form/inputField.tsx index f9ee8a5..c2c322f 100755 --- a/packages/ui/src/form/inputField.tsx +++ b/packages/ui/src/form/inputField.tsx @@ -1,5 +1,5 @@ +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import cs from "classnames"; -import type React from "react"; import { useController } from "react-hook-form"; import type { Control, FieldPath, FieldValues } from "react-hook-form"; @@ -13,6 +13,7 @@ type InputFieldProps = { inputClassName?: string; disabled?: boolean; onChange?: (value: string) => void; + showSearchIcon?: boolean; }; function InputField({ @@ -25,6 +26,7 @@ function InputField({ inputClassName, disabled, onChange, + showSearchIcon = false, }: InputFieldProps) { const { field, @@ -42,7 +44,7 @@ function InputField({ }; return ( -
+
+ {showSearchIcon && ( +
+ +
+ )} ({ className={cs( "w-full px-4 py-[13px] bg-white rounded-lg border text-base font-normal font-['Inter'] leading-normal focus:outline-none focus:ring-2 focus:ring-[#ffc222] focus:border-transparent", { + "pl-10 pr-4": showSearchIcon, + "px-4": !showSearchIcon, "border-[#d32a1b]": error, "border-[#c7ccd0]": !error, "text-[#1f1f20]": field.value, "text-[#788788]": !field.value, }, + "py-[13px]", )} placeholder={placeholder} disabled={disabled}