From 7605e59d10824e86e004ee3f084286de1ca5ba0f Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sun, 26 Nov 2023 21:42:34 -0500 Subject: [PATCH 1/3] Fix: update styles for beer card and brewery card --- src/components/BeerIndex/BeerCard.tsx | 8 ++++---- src/components/BreweryIndex/BreweryCard.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/BeerIndex/BeerCard.tsx b/src/components/BeerIndex/BeerCard.tsx index 4c3c652..09e03d4 100644 --- a/src/components/BeerIndex/BeerCard.tsx +++ b/src/components/BeerIndex/BeerCard.tsx @@ -13,8 +13,8 @@ const BeerCard: FC<{ post: z.infer }> = ({ post }) = return (
- -
+
+ {post.beerImages.length > 0 && ( }> = ({ post }) = className="h-full object-cover" /> )} -
- + +
diff --git a/src/components/BreweryIndex/BreweryCard.tsx b/src/components/BreweryIndex/BreweryCard.tsx index a60d0a7..9d87d80 100644 --- a/src/components/BreweryIndex/BreweryCard.tsx +++ b/src/components/BreweryIndex/BreweryCard.tsx @@ -14,8 +14,8 @@ const BreweryCard: FC<{ brewery: z.infer }> = ({ const { likeCount, mutate, isLoading } = useGetBreweryPostLikeCount(brewery.id); return (
- -
+
+ {brewery.breweryImages.length > 0 && ( }> = ({ className="h-full object-cover" /> )} -
- + +
From c0d90a84d89edb500fa67cb7e51ba40d0fe0a574 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sun, 26 Nov 2023 21:43:21 -0500 Subject: [PATCH 2/3] Fix: additional style updates --- src/components/ui/CustomToast.tsx | 2 +- src/pages/users/[id].tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/CustomToast.tsx b/src/components/ui/CustomToast.tsx index 86d3068..3f29d84 100644 --- a/src/components/ui/CustomToast.tsx +++ b/src/components/ui/CustomToast.tsx @@ -32,7 +32,7 @@ const CustomToast: FC<{ children: ReactNode }> = ({ children }) => { const alertType = toastToClassName(t.type); return (

{resolveValue(t.message, t)}

{t.type !== 'loading' && ( diff --git a/src/pages/users/[id].tsx b/src/pages/users/[id].tsx index 60f188a..02d713b 100644 --- a/src/pages/users/[id].tsx +++ b/src/pages/users/[id].tsx @@ -36,7 +36,7 @@ const UserInfoPage: FC = ({ user }) => { <> -
+
Date: Sun, 26 Nov 2023 21:45:03 -0500 Subject: [PATCH 3/3] Feat: add user posts functionality for account page --- src/components/Account/BeerPostsByUser.tsx | 82 ++++++++++++++++++ src/components/Account/BreweryPostsByUser.tsx | 84 +++++++++++++++++++ src/components/Account/UserPosts.tsx | 31 +++++++ .../beer-posts/useBeerPostsByUser.ts | 62 ++++++++++++++ .../brewery-posts/useBreweryPostsByUser.ts | 60 +++++++++++++ src/pages/account/index.tsx | 11 ++- src/pages/api/users/[id]/posts/beers.ts | 58 +++++++++++++ src/pages/api/users/[id]/posts/breweries.ts | 62 ++++++++++++++ .../BeerPost/getBeerPostsByPostedById.ts | 47 +++++++++++ .../getAllBreweryPostsByPostedById.ts | 58 +++++++++++++ 10 files changed, 552 insertions(+), 3 deletions(-) create mode 100644 src/components/Account/BeerPostsByUser.tsx create mode 100644 src/components/Account/BreweryPostsByUser.tsx create mode 100644 src/components/Account/UserPosts.tsx create mode 100644 src/hooks/data-fetching/beer-posts/useBeerPostsByUser.ts create mode 100644 src/hooks/data-fetching/brewery-posts/useBreweryPostsByUser.ts create mode 100644 src/pages/api/users/[id]/posts/beers.ts create mode 100644 src/pages/api/users/[id]/posts/breweries.ts create mode 100644 src/services/BeerPost/getBeerPostsByPostedById.ts create mode 100644 src/services/BreweryPost/getAllBreweryPostsByPostedById.ts diff --git a/src/components/Account/BeerPostsByUser.tsx b/src/components/Account/BeerPostsByUser.tsx new file mode 100644 index 0000000..3f079ab --- /dev/null +++ b/src/components/Account/BeerPostsByUser.tsx @@ -0,0 +1,82 @@ +import UserContext from '@/contexts/UserContext'; +import useBeerPostsByUser from '@/hooks/data-fetching/beer-posts/useBeerPostsByUser'; +import { FC, useContext, MutableRefObject, useRef } from 'react'; +import { FaArrowUp } from 'react-icons/fa'; +import { useInView } from 'react-intersection-observer'; +import BeerCard from '../BeerIndex/BeerCard'; +import LoadingCard from '../ui/LoadingCard'; +import Spinner from '../ui/Spinner'; + +const BeerPostsByUser: FC = () => { + const { user } = useContext(UserContext); + const pageRef: MutableRefObject = useRef(null); + const PAGE_SIZE = 2; + const { beerPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } = + useBeerPostsByUser({ pageSize: PAGE_SIZE, userId: user!.id }); + const { ref: lastBeerPostRef } = useInView({ + onChange: (visible) => { + if (!visible || isAtEnd) return; + setSize(size + 1); + }, + }); + return ( +
+
+ {!!beerPosts.length && !isLoading && ( + <> + {beerPosts.map((beerPost, i) => { + return ( +
+ +
+ ); + })} + + )} + {(isLoading || isLoadingMore) && ( + <> + {Array.from({ length: PAGE_SIZE }, (_, i) => ( + + ))} + + )} +
+ + {(isLoading || isLoadingMore) && ( +
+ +
+ )} + + {!!beerPosts.length && isAtEnd && !isLoading && ( +
+
+ +
+
+ )} + + {!beerPosts.length && !isLoading && ( +
+

No posts yet.

+
+ )} +
+ ); +}; + +export default BeerPostsByUser; diff --git a/src/components/Account/BreweryPostsByUser.tsx b/src/components/Account/BreweryPostsByUser.tsx new file mode 100644 index 0000000..95504bf --- /dev/null +++ b/src/components/Account/BreweryPostsByUser.tsx @@ -0,0 +1,84 @@ +import UserContext from '@/contexts/UserContext'; +import { FC, useContext, MutableRefObject, useRef } from 'react'; +import { FaArrowUp } from 'react-icons/fa'; +import { useInView } from 'react-intersection-observer'; +import useBreweryPostsByUser from '@/hooks/data-fetching/brewery-posts/useBreweryPostsByUser'; +import LoadingCard from '../ui/LoadingCard'; +import Spinner from '../ui/Spinner'; +import BreweryCard from '../BreweryIndex/BreweryCard'; + +const BreweryPostsByUser: FC = () => { + const { user } = useContext(UserContext); + const pageRef: MutableRefObject = useRef(null); + const PAGE_SIZE = 2; + const { breweryPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } = + useBreweryPostsByUser({ pageSize: PAGE_SIZE, userId: user!.id }); + + const { ref: lastBreweryPostRef } = useInView({ + onChange: (visible) => { + if (!visible || isAtEnd) return; + setSize(size + 1); + }, + }); + + return ( +
+
+ {!!breweryPosts.length && !isLoading && ( + <> + {breweryPosts.map((breweryPost, i) => { + return ( +
+ +
+ ); + })} + + )} + {isLoadingMore && ( + <> + {Array.from({ length: PAGE_SIZE }, (_, i) => ( + + ))} + + )} +
+ + {(isLoading || isLoadingMore) && ( +
+ +
+ )} + + {!!breweryPosts.length && isAtEnd && !isLoading && ( +
+
+ +
+
+ )} + + {!breweryPosts.length && !isLoading && ( +
+

No posts yet.

+
+ )} +
+ ); +}; + +export default BreweryPostsByUser; diff --git a/src/components/Account/UserPosts.tsx b/src/components/Account/UserPosts.tsx new file mode 100644 index 0000000..a41cbd5 --- /dev/null +++ b/src/components/Account/UserPosts.tsx @@ -0,0 +1,31 @@ +import { Tab } from '@headlessui/react'; +import { FC } from 'react'; +import BeerPostsByUser from './BeerPostsByUser'; +import BreweryPostsByUser from './BreweryPostsByUser'; + +const UserPosts: FC = () => { + return ( +
+
+ + + Beers + + Breweries + + + + + + + + + + + +
+
+ ); +}; + +export default UserPosts; diff --git a/src/hooks/data-fetching/beer-posts/useBeerPostsByUser.ts b/src/hooks/data-fetching/beer-posts/useBeerPostsByUser.ts new file mode 100644 index 0000000..90a099c --- /dev/null +++ b/src/hooks/data-fetching/beer-posts/useBeerPostsByUser.ts @@ -0,0 +1,62 @@ +import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWRInfinite from 'swr/infinite'; +import { z } from 'zod'; + +interface UseBeerPostsByUserParams { + pageSize: number; + userId: string; +} + +const useBeerPostsByUser = ({ pageSize, userId }: UseBeerPostsByUserParams) => { + 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/users/${userId}/posts/beers?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 useBeerPostsByUser; diff --git a/src/hooks/data-fetching/brewery-posts/useBreweryPostsByUser.ts b/src/hooks/data-fetching/brewery-posts/useBreweryPostsByUser.ts new file mode 100644 index 0000000..656cebd --- /dev/null +++ b/src/hooks/data-fetching/brewery-posts/useBreweryPostsByUser.ts @@ -0,0 +1,60 @@ +import BreweryPostQueryResult from '@/services/BreweryPost/schema/BreweryPostQueryResult'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWRInfinite from 'swr/infinite'; +import { z } from 'zod'; + +interface UseBreweryPostsByUserParams { + pageSize: number; + userId: string; +} + +const useBreweryPostsByUser = ({ pageSize, userId }: UseBreweryPostsByUserParams) => { + 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(BreweryPostQueryResult).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 { breweryPosts: parsedPayload.data, pageCount }; + }; + + const { data, error, isLoading, setSize, size } = useSWRInfinite( + (index) => + `/api/users/${userId}/posts/breweries?page_num=${index + 1}&page_size=${pageSize}`, + fetcher, + { parallel: true }, + ); + + const breweryPosts = data?.flatMap((d) => d.breweryPosts) ?? []; + const pageCount = data?.[0].pageCount ?? 0; + const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined'; + const isAtEnd = !(size < data?.[0].pageCount!); + + return { + breweryPosts, + pageCount, + size, + setSize, + isLoading, + isLoadingMore, + isAtEnd, + error: error as unknown, + }; +}; + +export default useBreweryPostsByUser; diff --git a/src/pages/account/index.tsx b/src/pages/account/index.tsx index fc13cfd..f5394f8 100644 --- a/src/pages/account/index.tsx +++ b/src/pages/account/index.tsx @@ -10,6 +10,7 @@ import Security from '@/components/Account/Security'; import DeleteAccount from '@/components/Account/DeleteAccount'; import accountPageReducer from '@/reducers/accountPageReducer'; import UserAvatar from '@/components/Account/UserAvatar'; +import UserPosts from '@/components/Account/UserPosts'; const AccountPage: NextPage = () => { const { user } = useContext(UserContext); @@ -32,9 +33,11 @@ const AccountPage: NextPage = () => { />
-
+
- +
+ +

Hello, {user!.username}!

@@ -58,7 +61,9 @@ const AccountPage: NextPage = () => { - Your posts! + + +
diff --git a/src/pages/api/users/[id]/posts/beers.ts b/src/pages/api/users/[id]/posts/beers.ts new file mode 100644 index 0000000..040e64b --- /dev/null +++ b/src/pages/api/users/[id]/posts/beers.ts @@ -0,0 +1,58 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import DBClient from '@/prisma/DBClient'; +import getBeerPostsByPostedById from '@/services/BeerPost/getBeerPostsByPostedById'; +import PaginatedQueryResponseSchema from '@/services/schema/PaginatedQueryResponseSchema'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +interface GetBeerPostsRequest extends NextApiRequest { + query: { + page_num: string; + page_size: string; + id: string; + }; +} + +const getBeerPostsByUserId = async ( + req: GetBeerPostsRequest, + res: NextApiResponse>, +) => { + const pageNum = parseInt(req.query.page_num, 10); + const pageSize = parseInt(req.query.page_size, 10); + + const { id } = req.query; + + const beerPosts = await getBeerPostsByPostedById({ pageNum, pageSize, postedById: id }); + + const beerPostCount = await DBClient.instance.beerPost.count({ + where: { postedBy: { id } }, + }); + + res.setHeader('X-Total-Count', beerPostCount); + + res.status(200).json({ + message: `Beer posts by user ${id} fetched successfully`, + statusCode: 200, + payload: beerPosts, + success: true, + }); +}; + +const router = createRouter< + GetBeerPostsRequest, + NextApiResponse> +>(); + +router.get( + validateRequest({ + querySchema: PaginatedQueryResponseSchema.extend({ id: z.string().cuid() }), + }), + getBeerPostsByUserId, +); + +const handler = router.handler(); + +export default handler; diff --git a/src/pages/api/users/[id]/posts/breweries.ts b/src/pages/api/users/[id]/posts/breweries.ts new file mode 100644 index 0000000..1b8975f --- /dev/null +++ b/src/pages/api/users/[id]/posts/breweries.ts @@ -0,0 +1,62 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import DBClient from '@/prisma/DBClient'; +import getAllBreweryPostsByPostedById from '@/services/BreweryPost/getAllBreweryPostsByPostedById'; +import PaginatedQueryResponseSchema from '@/services/schema/PaginatedQueryResponseSchema'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +interface GetBreweryPostsRequest extends NextApiRequest { + query: { + page_num: string; + page_size: string; + id: string; + }; +} + +const getBreweryPostsByUserId = async ( + req: GetBreweryPostsRequest, + res: NextApiResponse>, +) => { + const pageNum = parseInt(req.query.page_num, 10); + const pageSize = parseInt(req.query.page_size, 10); + + const { id } = req.query; + + const breweryPosts = await getAllBreweryPostsByPostedById({ + pageNum, + pageSize, + postedById: id, + }); + + const breweryPostCount = await DBClient.instance.breweryPost.count({ + where: { postedBy: { id } }, + }); + + res.setHeader('X-Total-Count', breweryPostCount); + + res.status(200).json({ + message: `Brewery posts by user ${id} fetched successfully`, + statusCode: 200, + payload: breweryPosts, + success: true, + }); +}; + +const router = createRouter< + GetBreweryPostsRequest, + NextApiResponse> +>(); + +router.get( + validateRequest({ + querySchema: PaginatedQueryResponseSchema.extend({ id: z.string().cuid() }), + }), + getBreweryPostsByUserId, +); + +const handler = router.handler(); + +export default handler; diff --git a/src/services/BeerPost/getBeerPostsByPostedById.ts b/src/services/BeerPost/getBeerPostsByPostedById.ts new file mode 100644 index 0000000..31ce792 --- /dev/null +++ b/src/services/BeerPost/getBeerPostsByPostedById.ts @@ -0,0 +1,47 @@ +import DBClient from '@/prisma/DBClient'; +import { z } from 'zod'; +import BeerPostQueryResult from './schema/BeerPostQueryResult'; + +interface GetBeerPostsByBeerStyleIdArgs { + postedById: string; + pageSize: number; + pageNum: number; +} + +const getBeerPostsByPostedById = async ({ + pageNum, + pageSize, + postedById, +}: GetBeerPostsByBeerStyleIdArgs): Promise[]> => { + const beers = await DBClient.instance.beerPost.findMany({ + where: { postedBy: { id: postedById } }, + take: pageSize, + skip: pageNum * pageSize, + select: { + id: true, + name: true, + ibu: true, + abv: true, + createdAt: true, + updatedAt: true, + description: true, + postedBy: { select: { username: true, id: true } }, + brewery: { select: { name: true, id: true } }, + style: { select: { name: true, id: true, description: true } }, + beerImages: { + select: { + alt: true, + path: true, + caption: true, + id: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + }); + + return beers; +}; + +export default getBeerPostsByPostedById; diff --git a/src/services/BreweryPost/getAllBreweryPostsByPostedById.ts b/src/services/BreweryPost/getAllBreweryPostsByPostedById.ts new file mode 100644 index 0000000..9b70cd8 --- /dev/null +++ b/src/services/BreweryPost/getAllBreweryPostsByPostedById.ts @@ -0,0 +1,58 @@ +import DBClient from '@/prisma/DBClient'; +import BreweryPostQueryResult from '@/services/BreweryPost/schema/BreweryPostQueryResult'; + +import { z } from 'zod'; + +const prisma = DBClient.instance; + +const getAllBreweryPostsByPostedById = async ({ + pageNum, + pageSize, + postedById, +}: { + pageNum: number; + pageSize: number; + postedById: string; +}): Promise[]> => { + const breweryPosts = await prisma.breweryPost.findMany({ + where: { postedBy: { id: postedById } }, + take: pageSize, + skip: (pageNum - 1) * pageSize, + select: { + id: true, + location: { + select: { + city: true, + address: true, + coordinates: true, + country: true, + stateOrProvince: true, + }, + }, + description: true, + name: true, + postedBy: { select: { username: true, id: true } }, + breweryImages: { + select: { + path: true, + caption: true, + id: true, + alt: true, + createdAt: true, + updatedAt: true, + }, + }, + createdAt: true, + dateEstablished: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + /** + * Prisma does not support tuples, so we have to typecast the coordinates field to + * [number, number] in order to satisfy the zod schema. + */ + return breweryPosts as Awaited>; +}; + +export default getAllBreweryPostsByPostedById;