Skip to content

Commit

Permalink
feat: add filter modal and enhance search functionality (#47)
Browse files Browse the repository at this point in the history
  • Loading branch information
AnoukRImola authored Nov 12, 2024
1 parent 961b159 commit da68acf
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 19 deletions.
4 changes: 4 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
183 changes: 183 additions & 0 deletions apps/web/src/app/_components/features/FilterModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-center items-start pt-5 overflow-y-auto"
onClick={onClose}
>
<motion.div
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 50, opacity: 0 }}
className="bg-white rounded-lg w-full max-w-md mx-4 my-5 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 flex justify-between items-center top-0 bg-white z-10">
<div className="flex justify-between items-center gap-4">
<h2 className="text-xl font-semibold">Filter by</h2>
{hasActiveFilters && (
<button
type="button"
onClick={handleClear}
className="text-gray-500 hover:text-gray-700"
>
Clear
</button>
)}
</div>
<button onClick={onClose} className="p-1" type="button">
<XMarkIcon className="h-6 w-6" />
</button>
</div>

<div className="p-4 space-y-6 overflow-y-auto">
<div className="space-y-3">
<h3 className="font-medium">Roast level</h3>
<div className="space-y-2">
{["Light", "Medium", "Strong"].map((strength) => (
<label
key={strength}
className="flex items-center space-x-2 py-3 border-b"
>
<input
type="checkbox"
checked={selectedStrength === strength}
onChange={() =>
setSelectedStrength(
selectedStrength === strength ? "" : strength,
)
}
className="w-5 h-5 rounded mr-1"
/>
<span>{strength}</span>
</label>
))}
</div>
</div>

<div className="space-y-3">
<h3 className="font-medium">Region</h3>
<div className="space-y-2">
{["Region A", "Region B", "Region C"].map((region) => (
<label
key={region}
className="flex items-center space-x-2 py-3 border-b"
>
<input
type="checkbox"
checked={selectedRegion === region}
onChange={() =>
setSelectedRegion(selectedRegion === region ? "" : region)
}
className="w-5 h-5 rounded mr-1"
/>
<span>{region}</span>
</label>
))}
</div>
</div>

<div className="space-y-3">
<h3 className="font-medium">Order by</h3>
<div className="space-y-2">
{["Highest price", "Lowest price"].map((order) => (
<label
key={order}
className="flex items-center space-x-2 py-3 border-b"
>
<input
type="radio"
name="orderBy"
checked={selectedOrder === order}
onChange={() => setSelectedOrder(order)}
className="w-5 h-5 rounded mr-1"
/>
<span>{order}</span>
</label>
))}
</div>
</div>
</div>

<div className="p-4 sticky bottom-0 bg-white">
<button
type="button"
onClick={handleApply}
className="w-full bg-surface-secondary-default text-black py-3 px-4 rounded-lg font-medium hover:bg-yellow-500 transition-colors"
>
Apply
</button>
</div>
</motion.div>
</motion.div>
);
}
36 changes: 27 additions & 9 deletions apps/web/src/app/_components/features/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(),
Expand All @@ -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<formData>({
resolver: zodResolver(searchSchema),
Expand Down Expand Up @@ -56,14 +59,29 @@ export default function SearchBar() {
};

return (
<div className="mb-2">
<InputField<formData>
name="region"
control={control}
label=""
placeholder="Search for your coffee"
onChange={(value: string) => handleInputChange(value)}
<>
<div className=" flex justify-center items-center mb-5">
<InputField<formData>
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}
/>
<button
type="button"
onClick={() => setIsFilterOpen(true)}
className="bg-surface-secondary-default p-3.5 rounded-lg"
>
<FunnelIcon className="h-6 w-6" />
</button>
</div>
<FilterModal
isOpen={isFilterOpen}
onClose={() => setIsFilterOpen(false)}
/>
</div>
</>
);
}
69 changes: 61 additions & 8 deletions apps/web/src/server/api/routers/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;

Expand All @@ -36,6 +38,7 @@ export const productRouter = createTRPCRouter({
nextCursor,
};
}),

searchProductCatalog: publicProcedure
.input(
z.object({
Expand All @@ -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,
};
}),
});
Binary file modified bun.lockb
Binary file not shown.
Loading

0 comments on commit da68acf

Please sign in to comment.