Add beer search feature

This commit adds the necessary components and hooks to implement a beer search feature on the website. It includes the following changes:

- Add a new BeerSearch API route that returns a list of beers matching a search query.
-  Implement a new hook useBeerPostSearch that utilizes SWR to fetch data from the API and parse it using a schema.
- Add a new page SearchPage that displays a search input field and a list of beer search results.
- Use lodash's debounce function to avoid making too many requests while the user is typing in the search input field.
This commit is contained in:
Aaron William Po
2023-03-27 19:01:51 -04:00
parent 7194f140aa
commit 2efc506250
6 changed files with 195 additions and 25 deletions

View File

@@ -0,0 +1,29 @@
import useSWR from 'swr';
import { beerPostQueryResultArraySchema } from '@/services/BeerPost/schema/BeerPostQueryResult';
const useBeerPostSearch = (query: string | undefined) => {
const { data, isLoading, error } = useSWR(
`/api/beers/search?search=${query}`,
async (url) => {
if (!query) return [];
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
const json = await response.json();
const result = beerPostQueryResultArraySchema.parse(json);
return result;
},
);
return {
searchResults: data,
searchError: error as Error | undefined,
isLoading,
};
};
export default useBeerPostSearch;

14
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"cookie": "0.5.0", "cookie": "0.5.0",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"lodash": "^4.17.21",
"multer": "^2.0.0-rc.4", "multer": "^2.0.0-rc.4",
"multer-storage-cloudinary": "^4.0.0", "multer-storage-cloudinary": "^4.0.0",
"next": "^13.2.1", "next": "^13.2.1",
@@ -42,6 +43,7 @@
"@types/cookie": "^0.5.1", "@types/cookie": "^0.5.1",
"@types/ejs": "^3.1.2", "@types/ejs": "^3.1.2",
"@types/jsonwebtoken": "^9.0.1", "@types/jsonwebtoken": "^9.0.1",
"@types/lodash": "^4.14.191",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^18.14.1", "@types/node": "^18.14.1",
"@types/passport-local": "^1.0.35", "@types/passport-local": "^1.0.35",
@@ -1464,6 +1466,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/lodash": {
"version": "4.14.191",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==",
"dev": true
},
"node_modules/@types/mdast": { "node_modules/@types/mdast": {
"version": "3.0.10", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@@ -11034,6 +11042,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/lodash": {
"version": "4.14.191",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==",
"dev": true
},
"@types/mdast": { "@types/mdast": {
"version": "3.0.10", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",

View File

@@ -23,20 +23,21 @@
"cookie": "0.5.0", "cookie": "0.5.0",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"multer-storage-cloudinary": "^4.0.0", "lodash": "^4.17.21",
"multer": "^2.0.0-rc.4", "multer": "^2.0.0-rc.4",
"next-connect": "^1.0.0-next.3", "multer-storage-cloudinary": "^4.0.0",
"next": "^13.2.1", "next": "^13.2.1",
"passport-local": "^1.0.0", "next-connect": "^1.0.0-next.3",
"passport": "^0.6.0", "passport": "^0.6.0",
"pino-pretty": "^9.3.0", "passport-local": "^1.0.0",
"pino": "^8.11.0", "pino": "^8.11.0",
"pino-pretty": "^9.3.0",
"react": "18.2.0",
"react-daisyui": "^3.0.3", "react-daisyui": "^3.0.3",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-email": "^1.7.15", "react-email": "^1.7.15",
"react-hook-form": "^7.43.2", "react-hook-form": "^7.43.2",
"react-icons": "^4.7.1", "react-icons": "^4.7.1",
"react": "18.2.0",
"sparkpost": "^2.1.4", "sparkpost": "^2.1.4",
"swr": "^2.0.3", "swr": "^2.0.3",
"zod": "^3.20.6" "zod": "^3.20.6"
@@ -46,6 +47,7 @@
"@types/cookie": "^0.5.1", "@types/cookie": "^0.5.1",
"@types/ejs": "^3.1.2", "@types/ejs": "^3.1.2",
"@types/jsonwebtoken": "^9.0.1", "@types/jsonwebtoken": "^9.0.1",
"@types/lodash": "^4.14.191",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^18.14.1", "@types/node": "^18.14.1",
"@types/passport-local": "^1.0.35", "@types/passport-local": "^1.0.35",

57
pages/api/beers/search.ts Normal file
View File

@@ -0,0 +1,57 @@
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiRequest, NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
import DBClient from '@/prisma/DBClient';
import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
const SearchSchema = z.object({
search: z.string().min(1),
});
interface SearchAPIRequest extends NextApiRequest {
query: z.infer<typeof SearchSchema>;
}
const search = async (req: SearchAPIRequest, res: NextApiResponse) => {
const { search: query } = req.query;
const beers: BeerPostQueryResult[] = await DBClient.instance.beerPost.findMany({
select: {
id: true,
name: true,
ibu: true,
abv: true,
createdAt: true,
description: true,
postedBy: { select: { username: true, id: true } },
brewery: { select: { name: true, id: true } },
type: { select: { name: true, id: true } },
beerImages: { select: { alt: true, path: true, caption: true, id: true } },
},
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
{ brewery: { name: { contains: query, mode: 'insensitive' } } },
{ type: { name: { contains: query, mode: 'insensitive' } } },
],
},
});
res.status(200).json(beers);
};
const router = createRouter<
SearchAPIRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.get(validateRequest({}), search);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -2,14 +2,11 @@ import { NextPage, GetServerSideProps } from 'next';
import Head from 'next/head'; import Head from 'next/head';
import Image from 'next/image'; import Image from 'next/image';
import { useState, useEffect } from 'react';
import BeerInfoHeader from '@/components/BeerById/BeerInfoHeader'; import BeerInfoHeader from '@/components/BeerById/BeerInfoHeader';
import BeerPostCommentsSection from '@/components/BeerById/BeerPostCommentsSection'; import BeerPostCommentsSection from '@/components/BeerById/BeerPostCommentsSection';
import BeerRecommendations from '@/components/BeerById/BeerRecommendations'; import BeerRecommendations from '@/components/BeerById/BeerRecommendations';
import Layout from '@/components/ui/Layout'; import Layout from '@/components/ui/Layout';
import DBClient from '@/prisma/DBClient';
import getAllBeerComments from '@/services/BeerComment/getAllBeerComments'; import getAllBeerComments from '@/services/BeerComment/getAllBeerComments';
import getBeerPostById from '@/services/BeerPost/getBeerPostById'; import getBeerPostById from '@/services/BeerPost/getBeerPostById';
import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations'; import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations';
@@ -17,6 +14,8 @@ import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations';
import { BeerCommentQueryResultArrayT } from '@/services/BeerComment/schema/BeerCommentQueryResult'; import { BeerCommentQueryResultArrayT } from '@/services/BeerComment/schema/BeerCommentQueryResult';
import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
import { BeerPost } from '@prisma/client'; import { BeerPost } from '@prisma/client';
import getBeerPostLikeCount from '@/services/BeerPostLike/getBeerPostLikeCount';
import getBeerCommentCount from '@/services/BeerComment/getBeerCommentCount';
interface BeerPageProps { interface BeerPageProps {
beerPost: BeerPostQueryResult; beerPost: BeerPostQueryResult;
@@ -36,12 +35,6 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
commentsPageCount, commentsPageCount,
likeCount, likeCount,
}) => { }) => {
const [comments, setComments] = useState(beerComments);
useEffect(() => {
setComments(beerComments);
}, [beerComments]);
return ( return (
<Layout> <Layout>
<Head> <Head>
@@ -65,8 +58,7 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
<div className="mt-4 flex flex-col space-y-3 md:flex-row md:space-y-0 md:space-x-3"> <div className="mt-4 flex flex-col space-y-3 md:flex-row md:space-y-0 md:space-x-3">
<BeerPostCommentsSection <BeerPostCommentsSection
beerPost={beerPost} beerPost={beerPost}
comments={comments} comments={beerComments}
setComments={setComments}
commentsPageCount={commentsPageCount} commentsPageCount={commentsPageCount}
/> />
<div className="md:w-[40%]"> <div className="md:w-[40%]">
@@ -82,7 +74,6 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
export const getServerSideProps: GetServerSideProps<BeerPageProps> = async (context) => { export const getServerSideProps: GetServerSideProps<BeerPageProps> = async (context) => {
const beerPost = await getBeerPostById(context.params!.id! as string); const beerPost = await getBeerPostById(context.params!.id! as string);
const beerCommentPageNum = parseInt(context.query.comments_page as string, 10) || 1; const beerCommentPageNum = parseInt(context.query.comments_page as string, 10) || 1;
if (!beerPost) { if (!beerPost) {
@@ -97,19 +88,17 @@ export const getServerSideProps: GetServerSideProps<BeerPageProps> = async (cont
{ id: beerPost.id }, { id: beerPost.id },
{ pageSize, pageNum: beerCommentPageNum }, { pageSize, pageNum: beerCommentPageNum },
); );
const numberOfPosts = await DBClient.instance.beerComment.count({
where: { beerPostId: beerPost.id }, const commentCount = await getBeerCommentCount(beerPost.id);
});
const pageCount = numberOfPosts ? Math.ceil(numberOfPosts / pageSize) : 0; const commentPageCount = commentCount ? Math.ceil(commentCount / pageSize) : 0;
const likeCount = await DBClient.instance.beerPostLike.count({ const likeCount = await getBeerPostLikeCount(beerPost.id);
where: { beerPostId: beerPost.id },
});
const props = { const props = {
beerPost: JSON.parse(JSON.stringify(beerPost)), beerPost: JSON.parse(JSON.stringify(beerPost)),
beerRecommendations: JSON.parse(JSON.stringify(beerRecommendations)), beerRecommendations: JSON.parse(JSON.stringify(beerRecommendations)),
beerComments: JSON.parse(JSON.stringify(beerComments)), beerComments: JSON.parse(JSON.stringify(beerComments)),
commentsPageCount: JSON.parse(JSON.stringify(pageCount)), commentsPageCount: JSON.parse(JSON.stringify(commentPageCount)),
likeCount: JSON.parse(JSON.stringify(likeCount)), likeCount: JSON.parse(JSON.stringify(likeCount)),
}; };

79
pages/beers/search.tsx Normal file
View File

@@ -0,0 +1,79 @@
import Layout from '@/components/ui/Layout';
import { NextPage } from 'next';
import { useRouter } from 'next/router';
import BeerCard from '@/components/BeerIndex/BeerCard';
import { ChangeEvent, useEffect, useState } from 'react';
import Spinner from '@/components/ui/Spinner';
import debounce from 'lodash/debounce';
import useBeerPostSearch from '@/hooks/useBeerPostSearch';
import FormLabel from '@/components/ui/forms/FormLabel';
const DEBOUNCE_DELAY = 300;
const SearchPage: NextPage = () => {
const router = useRouter();
const querySearch = router.query.search as string | undefined;
const [searchValue, setSearchValue] = useState(querySearch || '');
const { searchResults, isLoading, searchError } = useBeerPostSearch(searchValue);
const debounceSearch = debounce((value: string) => {
router.push({
pathname: '/beers/search',
query: { search: value },
});
}, DEBOUNCE_DELAY);
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setSearchValue(value);
debounceSearch(value);
};
useEffect(() => {
debounce(() => {
if (!querySearch || searchValue) {
return;
}
setSearchValue(searchValue);
}, DEBOUNCE_DELAY)();
}, [querySearch, searchValue]);
const showSearchResults = !isLoading && searchResults && !searchError;
return (
<Layout>
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="h-full w-full space-y-20">
<div className="flex h-[50%] w-full items-center justify-center bg-base-200">
<div className="w-8/12">
<FormLabel htmlFor="search">What are you looking for?</FormLabel>
<input
type="text"
id="search"
className="input-bordered input w-full rounded-lg"
onChange={onChange}
value={searchValue}
/>
</div>
</div>
<div className="flex flex-col items-center justify-center">
{!showSearchResults ? (
<Spinner size="lg" />
) : (
<div className="grid w-8/12 gap-4 md:grid-cols-2 lg:grid-cols-3">
{searchResults.map((result) => {
return <BeerCard key={result.id} post={result} />;
})}
</div>
)}
</div>
</div>
</div>
</Layout>
);
};
export default SearchPage;