diff --git a/src/components/BeerById/BeerPostCommentsSection.tsx b/src/components/BeerById/BeerPostCommentsSection.tsx index 2c732ab..8bf16b7 100644 --- a/src/components/BeerById/BeerPostCommentsSection.tsx +++ b/src/components/BeerById/BeerPostCommentsSection.tsx @@ -6,6 +6,7 @@ import { FC, MutableRefObject, useContext, useRef } from 'react'; import { z } from 'zod'; import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments'; import { useRouter } from 'next/router'; +import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema'; import BeerCommentForm from './BeerCommentForm'; import LoadingComponent from './LoadingComponent'; @@ -20,29 +21,25 @@ const BeerPostCommentsSection: FC = ({ beerPost }) const router = useRouter(); const pageNum = parseInt(router.query.comments_page as string, 10) || 1; - const PAGE_SIZE = 4; + const PAGE_SIZE = 15; const { comments, isLoading, mutate, setSize, size, isLoadingMore, isAtEnd } = - useBeerPostComments({ - id: beerPost.id, - pageNum, - pageSize: PAGE_SIZE, - }); + useBeerPostComments({ id: beerPost.id, pageNum, pageSize: PAGE_SIZE }); const commentSectionRef: MutableRefObject = useRef(null); - async function handleDeleteRequest(id: string) { + const handleDeleteRequest = async (id: string) => { const response = await fetch(`/api/beer-comments/${id}`, { method: 'DELETE' }); if (!response.ok) { throw new Error('Failed to delete comment.'); } - } + }; - async function handleEditRequest( + const handleEditRequest = async ( id: string, - data: { content: string; rating: number }, - ) { + data: z.infer, + ) => { const response = await fetch(`/api/beer-comments/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -52,7 +49,7 @@ const BeerPostCommentsSection: FC = ({ beerPost }) if (!response.ok) { throw new Error('Failed to update comment.'); } - } + }; return (
diff --git a/src/components/BeerById/BeerRecommendations.tsx b/src/components/BeerById/BeerRecommendations.tsx index 1e3b3bb..5be84b4 100644 --- a/src/components/BeerById/BeerRecommendations.tsx +++ b/src/components/BeerById/BeerRecommendations.tsx @@ -1,40 +1,101 @@ -import BeerRecommendationQueryResult from '@/services/BeerPost/schema/BeerRecommendationQueryResult'; import Link from 'next/link'; -import { FunctionComponent } from 'react'; +import { FC, MutableRefObject, useRef } from 'react'; +import { useInView } from 'react-intersection-observer'; +import { z } from 'zod'; +import useBeerRecommendations from '@/hooks/data-fetching/beer-posts/useBeerRecommendations'; +import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; +import debounce from 'lodash/debounce'; +import BeerRecommendationLoadingComponent from './BeerRecommendationLoadingComponent'; + +const BeerRecommendationsSection: FC<{ + beerPost: z.infer; +}> = ({ beerPost }) => { + const PAGE_SIZE = 10; + + const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = useBeerRecommendations({ + beerPost, + pageSize: PAGE_SIZE, + }); + + const { ref: penultimateBeerPostRef } = useInView({ + /** + * When the last beer post comes into view, call setSize from useBeerPostsByBrewery to + * load more beer posts. + */ + onChange: (visible) => { + if (!visible || isAtEnd) return; + debounce(() => setSize(size + 1), 200)(); + }, + }); + + const beerRecommendationsRef: MutableRefObject = useRef(null); -interface BeerRecommendationsProps { - beerRecommendations: BeerRecommendationQueryResult[]; -} -const BeerRecommendations: FunctionComponent = ({ - beerRecommendations, -}) => { return ( -
-
- {beerRecommendations.map((beerPost) => ( -
+
+
+ <> +
- -

- {beerPost.name} -

- - -

- {beerPost.brewery.name} -

- -
- -
- {beerPost.abv}% ABV - {beerPost.ibu} IBU +

Also check out

- ))} + + {!!beerPosts.length && ( +
+ {beerPosts.map((post, index) => { + const isPenultimateBeerPost = index === beerPosts.length - 2; + + /** + * Attach a ref to the second last beer post in the list. When it comes + * into view, the component will call setSize to load more beer posts. + */ + + return ( +
+
+ + {post.name} + + + + {post.brewery.name} + +
+ +
+
+ {post.type.name} +
+
+ {post.abv}% ABV + {post.ibu} IBU +
+
+
+ ); + })} +
+ )} + + { + /** + * If there are more beer posts to load, show a loading component with a + * skeleton loader and a loading spinner. + */ + !!isLoadingMore && !isAtEnd && ( + + ) + } +
); }; -export default BeerRecommendations; +export default BeerRecommendationsSection; diff --git a/src/components/BreweryById/BreweryBeerSection.tsx b/src/components/BreweryById/BreweryBeerSection.tsx index 4f0ba6b..32bb967 100644 --- a/src/components/BreweryById/BreweryBeerSection.tsx +++ b/src/components/BreweryById/BreweryBeerSection.tsx @@ -1,10 +1,11 @@ import UseBeerPostsByBrewery from '@/hooks/data-fetching/beer-posts/useBeerPostsByBrewery'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; import Link from 'next/link'; -import { FC } from 'react'; +import { FC, MutableRefObject, useContext, useRef } from 'react'; import { useInView } from 'react-intersection-observer'; import { z } from 'zod'; import { FaPlus } from 'react-icons/fa'; +import UserContext from '@/contexts/userContext'; import BeerRecommendationLoadingComponent from '../BeerById/BeerRecommendationLoadingComponent'; interface BreweryCommentsSectionProps { @@ -13,6 +14,8 @@ interface BreweryCommentsSectionProps { const BreweryBeersSection: FC = ({ breweryPost }) => { const PAGE_SIZE = 2; + const { user } = useContext(UserContext); + const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = UseBeerPostsByBrewery({ breweryId: breweryPost.id, pageSize: PAGE_SIZE, @@ -28,8 +31,10 @@ const BreweryBeersSection: FC = ({ breweryPost }) = }, }); + const beerRecommendationsRef: MutableRefObject = useRef(null); + return ( -
+
<>
@@ -37,13 +42,15 @@ const BreweryBeersSection: FC = ({ breweryPost }) =

Brews

- - - Add Beer - + {user && ( + + + Add Beer + + )}
diff --git a/src/components/ui/CommentsComponent.tsx b/src/components/ui/CommentsComponent.tsx index ece5752..fb08e56 100644 --- a/src/components/ui/CommentsComponent.tsx +++ b/src/components/ui/CommentsComponent.tsx @@ -49,9 +49,10 @@ const CommentsComponent: FC = ({ handleEditRequest, }) => { const { ref: penultimateCommentRef } = useInView({ + threshold: 0.1, /** - * When the second last comment comes into view, call setSize from useBeerPostComments - * to load more comments. + * When the last comment comes into view, call setSize from useBeerPostComments to + * load more comments. */ onChange: (visible) => { if (!visible || isAtEnd) return; @@ -62,9 +63,9 @@ const CommentsComponent: FC = ({ return ( <> {!!comments.length && ( -
+
{comments.map((comment, index) => { - const isPenultimateComment = index === comments.length - 2; + const isLastComment = index === comments.length - 1; /** * Attach a ref to the last comment in the list. When it comes into view, the @@ -72,7 +73,7 @@ const CommentsComponent: FC = ({ */ return (
; +} + +/** + * A custom hook using SWR to fetch beer recommendations from the API. + * + * @param options The options to use when fetching beer recommendations. + * @param options.pageSize The number of beer recommendations to fetch per page. + * @param options.beerPost The beer post to fetch recommendations for. + * @returns An object with the following properties: + * + * - `beerPosts`: The beer posts fetched from the API. + * - `error`: The error that occurred while fetching the data. + * - `isAtEnd`: A boolean indicating whether all data has been fetched. + * - `isLoading`: A boolean indicating whether the data is being fetched. + * - `isLoadingMore`: A boolean indicating whether more data is being fetched. + * - `pageCount`: The total number of pages of data. + * - `setSize`: A function to set the size of the data. + * - `size`: The size of the data. + */ +const UseBeerPostsByBrewery = ({ pageSize, beerPost }: UseBeerRecommendationsParams) => { + const fetcher = async (url: string) => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(response.statusText); + } + + const json = await response.json(); + const count = response.headers.get('X-Total-Count'); + + const parsed = APIResponseValidationSchema.safeParse(json); + if (!parsed.success) { + throw new Error('API response validation failed'); + } + + const parsedPayload = z.array(beerPostQueryResult).safeParse(parsed.data.payload); + if (!parsedPayload.success) { + throw new Error('API response validation failed'); + } + + const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize); + return { + beerPosts: parsedPayload.data, + pageCount, + }; + }; + + const { data, error, isLoading, setSize, size } = useSWRInfinite( + (index) => + `/api/beers/${beerPost.id}/recommendations/?page_num=${ + index + 1 + }&page_size=${pageSize}`, + fetcher, + ); + + const beerPosts = data?.flatMap((d) => d.beerPosts) ?? []; + const pageCount = data?.[0].pageCount ?? 0; + const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined'; + const isAtEnd = !(size < data?.[0].pageCount!); + + return { + beerPosts, + pageCount, + size, + setSize, + isLoading, + isLoadingMore, + isAtEnd, + error: error as unknown, + }; +}; + +export default UseBeerPostsByBrewery; diff --git a/src/pages/api/beers/[id]/recommendations.ts b/src/pages/api/beers/[id]/recommendations.ts new file mode 100644 index 0000000..28c7c23 --- /dev/null +++ b/src/pages/api/beers/[id]/recommendations.ts @@ -0,0 +1,63 @@ +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import ServerError from '@/config/util/ServerError'; +import getBeerPostById from '@/services/BeerPost/getBeerPostById'; +import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface BeerPostRequest extends NextApiRequest { + query: { id: string; page_num: string; page_size: string }; +} + +const router = createRouter< + BeerPostRequest, + NextApiResponse> +>(); + +const getBeerRecommendationsRequest = async ( + req: BeerPostRequest, + res: NextApiResponse>, +) => { + const { id } = req.query; + + const beerPost = await getBeerPostById(id); + + if (!beerPost) { + throw new ServerError('Beer post not found', 404); + } + + const pageNum = parseInt(req.query.page_num as string, 10); + const pageSize = parseInt(req.query.page_size as string, 10); + + const { count, beerRecommendations } = await getBeerRecommendations({ + beerPost, + pageNum, + pageSize, + }); + + res.setHeader('X-Total-Count', count); + res.status(200).json({ + success: true, + message: 'Recommendations fetched successfully', + statusCode: 200, + payload: beerRecommendations, + }); +}; + +router.get( + validateRequest({ + querySchema: z.object({ + id: z.string().uuid(), + page_num: z.string().regex(/^[0-9]+$/), + page_size: z.string().regex(/^[0-9]+$/), + }), + }), + getBeerRecommendationsRequest, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/beers/[id]/index.tsx b/src/pages/beers/[id]/index.tsx index dcf4e02..facda0c 100644 --- a/src/pages/beers/[id]/index.tsx +++ b/src/pages/beers/[id]/index.tsx @@ -7,10 +7,8 @@ import BeerPostCommentsSection from '@/components/BeerById/BeerPostCommentsSecti import BeerRecommendations from '@/components/BeerById/BeerRecommendations'; import getBeerPostById from '@/services/BeerPost/getBeerPostById'; -import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; -import { BeerPost } from '@prisma/client'; import { z } from 'zod'; @@ -21,13 +19,9 @@ import { Tab } from '@headlessui/react'; interface BeerPageProps { beerPost: z.infer; - beerRecommendations: (BeerPost & { - brewery: { id: string; name: string }; - beerImages: { id: string; alt: string; url: string }[]; - })[]; } -const BeerByIdPage: NextPage = ({ beerPost, beerRecommendations }) => { +const BeerByIdPage: NextPage = ({ beerPost }) => { const isDesktop = useMediaQuery('(min-width: 1024px)'); return ( @@ -72,7 +66,7 @@ const BeerByIdPage: NextPage = ({ beerPost, beerRecommendations }
- +
) : ( @@ -90,7 +84,7 @@ const BeerByIdPage: NextPage = ({ beerPost, beerRecommendations } - + @@ -109,12 +103,8 @@ export const getServerSideProps: GetServerSideProps = async (cont return { notFound: true }; } - const { type, brewery, id } = beerPost; - const beerRecommendations = await getBeerRecommendations({ type, brewery, id }); - const props = { beerPost: JSON.parse(JSON.stringify(beerPost)), - beerRecommendations: JSON.parse(JSON.stringify(beerRecommendations)), }; return { props }; diff --git a/src/pages/beers/index.tsx b/src/pages/beers/index.tsx index 453599d..195f4ca 100644 --- a/src/pages/beers/index.tsx +++ b/src/pages/beers/index.tsx @@ -9,7 +9,7 @@ import { FaArrowUp } from 'react-icons/fa'; import LoadingCard from '@/components/ui/LoadingCard'; const BeerPage: NextPage = () => { - const PAGE_SIZE = 6; + const PAGE_SIZE = 20; const { beerPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } = useBeerPosts({ pageSize: PAGE_SIZE, diff --git a/src/pages/breweries/index.tsx b/src/pages/breweries/index.tsx index 96e9add..6261df3 100644 --- a/src/pages/breweries/index.tsx +++ b/src/pages/breweries/index.tsx @@ -17,7 +17,7 @@ interface BreweryPageProps { } const BreweryPage: NextPage = () => { - const PAGE_SIZE = 6; + const PAGE_SIZE = 20; const { breweryPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } = useBreweryPosts({ diff --git a/src/services/BeerPost/getBeerRecommendations.ts b/src/services/BeerPost/getBeerRecommendations.ts index ca900c4..69a44e0 100644 --- a/src/services/BeerPost/getBeerRecommendations.ts +++ b/src/services/BeerPost/getBeerRecommendations.ts @@ -1,22 +1,51 @@ import DBClient from '@/prisma/DBClient'; -import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; +import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; + import { z } from 'zod'; -const getBeerRecommendations = async ( - beerPost: Pick, 'type' | 'brewery' | 'id'>, -) => { - const beerRecommendations = await DBClient.instance.beerPost.findMany({ +interface GetBeerRecommendationsArgs { + beerPost: z.infer; + pageNum: number; + pageSize: number; +} + +const getBeerRecommendations = async ({ + beerPost, + pageNum, + pageSize, +}: GetBeerRecommendationsArgs) => { + const skip = (pageNum - 1) * pageSize; + const take = pageSize; + const beerRecommendations: z.infer[] = + await DBClient.instance.beerPost.findMany({ + where: { + OR: [{ typeId: beerPost.type.id }, { breweryId: beerPost.brewery.id }], + NOT: { id: beerPost.id }, + }, + select: { + id: true, + name: true, + ibu: true, + abv: true, + description: true, + createdAt: true, + type: { select: { name: true, id: true } }, + brewery: { select: { name: true, id: true } }, + postedBy: { select: { id: true, username: true } }, + beerImages: { select: { path: true, caption: true, id: true, alt: true } }, + }, + take, + skip, + }); + + const count = await DBClient.instance.beerPost.count({ where: { OR: [{ typeId: beerPost.type.id }, { breweryId: beerPost.brewery.id }], NOT: { id: beerPost.id }, }, - include: { - beerImages: { select: { id: true, path: true, caption: true, alt: true } }, - brewery: { select: { id: true, name: true } }, - }, }); - return beerRecommendations; + return { beerRecommendations, count }; }; export default getBeerRecommendations;