From cf6a8309f1590a49d0cbde1c0ab364c5e6ad29ca Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sun, 19 Mar 2023 18:04:13 -0400 Subject: [PATCH 1/4] Rework pagination and cookies --- .../BeerIndex/BeerIndexPaginationBar.tsx | 34 +++++++++++++++++ components/BeerIndex/Pagination.tsx | 37 ------------------- components/ui/forms/FormPageLayout.tsx | 2 +- config/auth/cookie.ts | 2 +- hooks/useUser.ts | 6 ++- pages/api/users/register.ts | 6 --- pages/beers/index.tsx | 16 ++++++-- 7 files changed, 53 insertions(+), 50 deletions(-) create mode 100644 components/BeerIndex/BeerIndexPaginationBar.tsx delete mode 100644 components/BeerIndex/Pagination.tsx diff --git a/components/BeerIndex/BeerIndexPaginationBar.tsx b/components/BeerIndex/BeerIndexPaginationBar.tsx new file mode 100644 index 0000000..c90217c --- /dev/null +++ b/components/BeerIndex/BeerIndexPaginationBar.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link'; + +import { FC } from 'react'; + +interface PaginationProps { + pageNum: number; + pageCount: number; +} + +const BeerIndexPaginationBar: FC = ({ pageCount, pageNum }) => { + return ( +
+ + « + + + + » + +
+ ); +}; + +export default BeerIndexPaginationBar; diff --git a/components/BeerIndex/Pagination.tsx b/components/BeerIndex/Pagination.tsx deleted file mode 100644 index dc18fb8..0000000 --- a/components/BeerIndex/Pagination.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useRouter } from 'next/router'; -import { FC } from 'react'; - -interface PaginationProps { - pageNum: number; - pageCount: number; -} - -const Pagination: FC = ({ pageCount, pageNum }) => { - const router = useRouter(); - - return ( -
- - - -
- ); -}; - -export default Pagination; diff --git a/components/ui/forms/FormPageLayout.tsx b/components/ui/forms/FormPageLayout.tsx index fddea6c..4fef0c1 100644 --- a/components/ui/forms/FormPageLayout.tsx +++ b/components/ui/forms/FormPageLayout.tsx @@ -22,7 +22,7 @@ const FormPageLayout: FC = ({
- +
diff --git a/config/auth/cookie.ts b/config/auth/cookie.ts index 464b8a2..50ac928 100644 --- a/config/auth/cookie.ts +++ b/config/auth/cookie.ts @@ -8,7 +8,7 @@ export const MAX_AGE = 60 * 60 * 8; // 8 hours export function setTokenCookie(res: NextApiResponse, token: string) { const cookie = serialize(TOKEN_NAME, token, { maxAge: MAX_AGE, - httpOnly: true, + httpOnly: false, secure: process.env.NODE_ENV === 'production', path: '/', sameSite: 'lax', diff --git a/hooks/useUser.ts b/hooks/useUser.ts index 14bdc2f..0d59f34 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -3,15 +3,18 @@ import APIResponseValidationSchema from '@/validation/APIResponseValidationSchem import useSWR from 'swr'; const useUser = () => { - // check cookies for user const { data: user, error, isLoading, } = useSWR('/api/users/current', async (url) => { + if (!document.cookie.includes('token')) { + throw new Error('No token cookie found'); + } const response = await fetch(url); if (!response.ok) { + document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; throw new Error(response.statusText); } @@ -23,6 +26,7 @@ const useUser = () => { } const parsedPayload = GetUserSchema.safeParse(parsed.data.payload); + console.log(parsedPayload); if (!parsedPayload.success) { throw new Error(parsedPayload.error.message); } diff --git a/pages/api/users/register.ts b/pages/api/users/register.ts index baaef64..ffe3f41 100644 --- a/pages/api/users/register.ts +++ b/pages/api/users/register.ts @@ -17,12 +17,6 @@ interface RegisterUserRequest extends NextApiRequest { body: z.infer; } -const { BASE_URL } = process.env; - -if (!BASE_URL) { - throw new ServerError('BASE_URL env variable is not set.', 500); -} - const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => { const [usernameTaken, emailTaken] = await Promise.all([ findUserByUsername(req.body.username), diff --git a/pages/beers/index.tsx b/pages/beers/index.tsx index aee56cd..a2cbd45 100644 --- a/pages/beers/index.tsx +++ b/pages/beers/index.tsx @@ -4,7 +4,7 @@ import getAllBeerPosts from '@/services/BeerPost/getAllBeerPosts'; import { useRouter } from 'next/router'; import DBClient from '@/prisma/DBClient'; import Layout from '@/components/ui/Layout'; -import Pagination from '@/components/BeerIndex/Pagination'; +import BeerIndexPaginationBar from '@/components/BeerIndex/BeerIndexPaginationBar'; import BeerCard from '@/components/BeerIndex/BeerCard'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult'; import Head from 'next/head'; @@ -26,16 +26,24 @@ const BeerPage: NextPage = ({ initialBeerPosts, pageCount }) => {
-
+
+
+
+

The Biergarten Index

+

+ Page {pageNum} of {pageCount} +

+
+
{initialBeerPosts.map((post) => { return ; })}
- +
-
+
); From 7194f140aac171898f6eec1e290a1704c8b3a14c Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 27 Mar 2023 19:00:12 -0400 Subject: [PATCH 2/4] refactor: update Spinner component to accept size prop This commit updates the `Spinner` component to accept a `size` prop that determines the width of the spinner --- components/ui/Spinner.tsx | 57 ++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/components/ui/Spinner.tsx b/components/ui/Spinner.tsx index 6dc37e4..a3fb412 100644 --- a/components/ui/Spinner.tsx +++ b/components/ui/Spinner.tsx @@ -1,23 +1,38 @@ -const Spinner = () => ( -
- - Loading... -
-); +import { FC } from 'react'; + +interface SpinnerProps { + size?: 'xs' | 'sm' | 'md' | 'lg'; +} + +const Spinner: FC = ({ size = 'md' }) => { + const spinnerWidths: Record, `w-[${number}px]`> = { + xs: 'w-[10px]', + sm: 'w-[20px]', + md: 'w-[100px]', + lg: 'w-[150px]', + }; + + return ( +
+ + Loading... +
+ ); +}; export default Spinner; From 2efc506250f40132ca8c37d152f24865300848d9 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 27 Mar 2023 19:01:51 -0400 Subject: [PATCH 3/4] 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. --- hooks/useBeerPostSearch.ts | 29 ++++++++++++++ package-lock.json | 14 +++++++ package.json | 12 +++--- pages/api/beers/search.ts | 57 +++++++++++++++++++++++++++ pages/beers/[id]/index.tsx | 29 +++++--------- pages/beers/search.tsx | 79 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 195 insertions(+), 25 deletions(-) create mode 100644 hooks/useBeerPostSearch.ts create mode 100644 pages/api/beers/search.ts create mode 100644 pages/beers/search.tsx diff --git a/hooks/useBeerPostSearch.ts b/hooks/useBeerPostSearch.ts new file mode 100644 index 0000000..f029f0d --- /dev/null +++ b/hooks/useBeerPostSearch.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index 6c13661..c7f85de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "cookie": "0.5.0", "date-fns": "^2.29.3", "jsonwebtoken": "^9.0.0", + "lodash": "^4.17.21", "multer": "^2.0.0-rc.4", "multer-storage-cloudinary": "^4.0.0", "next": "^13.2.1", @@ -42,6 +43,7 @@ "@types/cookie": "^0.5.1", "@types/ejs": "^3.1.2", "@types/jsonwebtoken": "^9.0.1", + "@types/lodash": "^4.14.191", "@types/multer": "^1.4.7", "@types/node": "^18.14.1", "@types/passport-local": "^1.0.35", @@ -1464,6 +1466,12 @@ "@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": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", @@ -11034,6 +11042,12 @@ "@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": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", diff --git a/package.json b/package.json index d674fd3..c6443f4 100644 --- a/package.json +++ b/package.json @@ -23,20 +23,21 @@ "cookie": "0.5.0", "date-fns": "^2.29.3", "jsonwebtoken": "^9.0.0", - "multer-storage-cloudinary": "^4.0.0", + "lodash": "^4.17.21", "multer": "^2.0.0-rc.4", - "next-connect": "^1.0.0-next.3", + "multer-storage-cloudinary": "^4.0.0", "next": "^13.2.1", - "passport-local": "^1.0.0", + "next-connect": "^1.0.0-next.3", "passport": "^0.6.0", - "pino-pretty": "^9.3.0", + "passport-local": "^1.0.0", "pino": "^8.11.0", + "pino-pretty": "^9.3.0", + "react": "18.2.0", "react-daisyui": "^3.0.3", "react-dom": "18.2.0", "react-email": "^1.7.15", "react-hook-form": "^7.43.2", "react-icons": "^4.7.1", - "react": "18.2.0", "sparkpost": "^2.1.4", "swr": "^2.0.3", "zod": "^3.20.6" @@ -46,6 +47,7 @@ "@types/cookie": "^0.5.1", "@types/ejs": "^3.1.2", "@types/jsonwebtoken": "^9.0.1", + "@types/lodash": "^4.14.191", "@types/multer": "^1.4.7", "@types/node": "^18.14.1", "@types/passport-local": "^1.0.35", diff --git a/pages/api/beers/search.ts b/pages/api/beers/search.ts new file mode 100644 index 0000000..7a42067 --- /dev/null +++ b/pages/api/beers/search.ts @@ -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; +} + +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> +>(); + +router.get(validateRequest({}), search); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/pages/beers/[id]/index.tsx b/pages/beers/[id]/index.tsx index 13892b2..2412704 100644 --- a/pages/beers/[id]/index.tsx +++ b/pages/beers/[id]/index.tsx @@ -2,14 +2,11 @@ import { NextPage, GetServerSideProps } from 'next'; import Head from 'next/head'; import Image from 'next/image'; -import { useState, useEffect } from 'react'; - import BeerInfoHeader from '@/components/BeerById/BeerInfoHeader'; import BeerPostCommentsSection from '@/components/BeerById/BeerPostCommentsSection'; import BeerRecommendations from '@/components/BeerById/BeerRecommendations'; import Layout from '@/components/ui/Layout'; -import DBClient from '@/prisma/DBClient'; import getAllBeerComments from '@/services/BeerComment/getAllBeerComments'; import getBeerPostById from '@/services/BeerPost/getBeerPostById'; import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations'; @@ -17,6 +14,8 @@ import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations'; import { BeerCommentQueryResultArrayT } from '@/services/BeerComment/schema/BeerCommentQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult'; import { BeerPost } from '@prisma/client'; +import getBeerPostLikeCount from '@/services/BeerPostLike/getBeerPostLikeCount'; +import getBeerCommentCount from '@/services/BeerComment/getBeerCommentCount'; interface BeerPageProps { beerPost: BeerPostQueryResult; @@ -36,12 +35,6 @@ const BeerByIdPage: NextPage = ({ commentsPageCount, likeCount, }) => { - const [comments, setComments] = useState(beerComments); - - useEffect(() => { - setComments(beerComments); - }, [beerComments]); - return ( @@ -65,8 +58,7 @@ const BeerByIdPage: NextPage = ({
@@ -82,7 +74,6 @@ const BeerByIdPage: NextPage = ({ export const getServerSideProps: GetServerSideProps = async (context) => { const beerPost = await getBeerPostById(context.params!.id! as string); - const beerCommentPageNum = parseInt(context.query.comments_page as string, 10) || 1; if (!beerPost) { @@ -97,19 +88,17 @@ export const getServerSideProps: GetServerSideProps = async (cont { id: beerPost.id }, { pageSize, pageNum: beerCommentPageNum }, ); - const numberOfPosts = await DBClient.instance.beerComment.count({ - where: { beerPostId: beerPost.id }, - }); - const pageCount = numberOfPosts ? Math.ceil(numberOfPosts / pageSize) : 0; - const likeCount = await DBClient.instance.beerPostLike.count({ - where: { beerPostId: beerPost.id }, - }); + + const commentCount = await getBeerCommentCount(beerPost.id); + + const commentPageCount = commentCount ? Math.ceil(commentCount / pageSize) : 0; + const likeCount = await getBeerPostLikeCount(beerPost.id); const props = { beerPost: JSON.parse(JSON.stringify(beerPost)), beerRecommendations: JSON.parse(JSON.stringify(beerRecommendations)), beerComments: JSON.parse(JSON.stringify(beerComments)), - commentsPageCount: JSON.parse(JSON.stringify(pageCount)), + commentsPageCount: JSON.parse(JSON.stringify(commentPageCount)), likeCount: JSON.parse(JSON.stringify(likeCount)), }; diff --git a/pages/beers/search.tsx b/pages/beers/search.tsx new file mode 100644 index 0000000..5eca34b --- /dev/null +++ b/pages/beers/search.tsx @@ -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) => { + 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 ( + +
+
+
+
+ What are you looking for? + +
+
+ +
+ {!showSearchResults ? ( + + ) : ( +
+ {searchResults.map((result) => { + return ; + })} +
+ )} +
+
+
+
+ ); +}; + +export default SearchPage; From d8a8dad37fe572c743a8190656dac78ca8984954 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 27 Mar 2023 19:02:38 -0400 Subject: [PATCH 4/4] Refactor beer by id page Extracted services to separate files. --- components/BeerById/BeerCommentForm.tsx | 4 +--- components/BeerById/BeerPostCommentsSection.tsx | 5 ++--- components/ui/Navbar.tsx | 6 +++--- services/BeerComment/getBeerCommentCount.ts | 11 +++++++++++ services/BeerPostLike/getBeerPostLikeCount.ts | 11 +++++++++++ 5 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 services/BeerComment/getBeerCommentCount.ts create mode 100644 services/BeerPostLike/getBeerPostLikeCount.ts diff --git a/components/BeerById/BeerCommentForm.tsx b/components/BeerById/BeerCommentForm.tsx index f85972d..3187ed5 100644 --- a/components/BeerById/BeerCommentForm.tsx +++ b/components/BeerById/BeerCommentForm.tsx @@ -1,10 +1,9 @@ import sendCreateBeerCommentRequest from '@/requests/sendCreateBeerCommentRequest'; -import { BeerCommentQueryResultArrayT } from '@/services/BeerComment/schema/BeerCommentQueryResult'; import BeerCommentValidationSchema from '@/services/BeerComment/schema/CreateBeerCommentValidationSchema'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/router'; -import { Dispatch, SetStateAction, FunctionComponent, useState, useEffect } from 'react'; +import { FunctionComponent, useState, useEffect } from 'react'; import { Rating } from 'react-daisyui'; import { useForm, SubmitHandler } from 'react-hook-form'; import { z } from 'zod'; @@ -18,7 +17,6 @@ import FormTextArea from '../ui/forms/FormTextArea'; interface BeerCommentFormProps { beerPost: BeerPostQueryResult; - setComments: Dispatch>; } const BeerCommentForm: FunctionComponent = ({ beerPost }) => { diff --git a/components/BeerById/BeerPostCommentsSection.tsx b/components/BeerById/BeerPostCommentsSection.tsx index 6a61623..a0824ba 100644 --- a/components/BeerById/BeerPostCommentsSection.tsx +++ b/components/BeerById/BeerPostCommentsSection.tsx @@ -9,14 +9,13 @@ import CommentCard from './CommentCard'; interface BeerPostCommentsSectionProps { beerPost: BeerPostQueryResult; - setComments: React.Dispatch>; comments: BeerCommentQueryResultArrayT; commentsPageCount: number; } const BeerPostCommentsSection: FC = ({ beerPost, - setComments, + comments, commentsPageCount, }) => { @@ -30,7 +29,7 @@ const BeerPostCommentsSection: FC = ({
{user ? ( - + ) : (
Log in to leave a comment. diff --git a/components/ui/Navbar.tsx b/components/ui/Navbar.tsx index 4803717..a329b6b 100644 --- a/components/ui/Navbar.tsx +++ b/components/ui/Navbar.tsx @@ -44,7 +44,7 @@ const Navbar = () => { return (
-
-