From adf1b55d1043c45a162ee055bca7b143c4edb811 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sun, 30 Apr 2023 23:09:03 -0400 Subject: [PATCH] Feat: Add create brewery comments and brewery cluster map --- src/components/BeerById/BeerCommentForm.tsx | 75 ++--------- .../BreweryById/BreweryCommentsSection.tsx | 107 +++++++++++++-- src/components/BreweryById/BreweryMap.tsx | 43 ------ src/components/BreweryById/BreweryPostMap.tsx | 55 ++++++++ src/components/ui/CommentForm.tsx | 85 ++++++++++++ src/components/ui/LocationMarker.tsx | 8 ++ .../api/breweries/[id]/comments/index.ts | 59 ++++---- src/pages/beers/[id]/index.tsx | 2 +- src/pages/breweries/[id].tsx | 7 +- src/pages/breweries/map.tsx | 127 ++++++++++++++++++ src/requests/sendBreweryPostLikeRequest.ts | 9 +- src/requests/sendCreateBeerCommentRequest.ts | 7 +- .../BreweryComment/createNewBreweryComment.ts | 33 +++++ 13 files changed, 452 insertions(+), 165 deletions(-) delete mode 100644 src/components/BreweryById/BreweryMap.tsx create mode 100644 src/components/BreweryById/BreweryPostMap.tsx create mode 100644 src/components/ui/CommentForm.tsx create mode 100644 src/components/ui/LocationMarker.tsx create mode 100644 src/pages/breweries/map.tsx create mode 100644 src/services/BreweryComment/createNewBreweryComment.ts diff --git a/src/components/BeerById/BeerCommentForm.tsx b/src/components/BeerById/BeerCommentForm.tsx index c9f89cd..2d7c47f 100644 --- a/src/components/BeerById/BeerCommentForm.tsx +++ b/src/components/BeerById/BeerCommentForm.tsx @@ -3,19 +3,13 @@ import sendCreateBeerCommentRequest from '@/requests/sendCreateBeerCommentReques import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { zodResolver } from '@hookform/resolvers/zod'; -import { FunctionComponent, useState, useEffect } from 'react'; -import { Rating } from 'react-daisyui'; +import { FunctionComponent } from 'react'; import { useForm, SubmitHandler } from 'react-hook-form'; import { z } from 'zod'; import useBeerPostComments from '@/hooks/useBeerPostComments'; import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema'; -import Button from '../ui/forms/Button'; -import FormError from '../ui/forms/FormError'; -import FormInfo from '../ui/forms/FormInfo'; -import FormLabel from '../ui/forms/FormLabel'; -import FormSegment from '../ui/forms/FormSegment'; -import FormTextArea from '../ui/forms/FormTextArea'; +import CommentForm from '../ui/CommentForm'; interface BeerCommentFormProps { beerPost: z.infer; @@ -26,26 +20,16 @@ const BeerCommentForm: FunctionComponent = ({ beerPost, mutate, }) => { - const { register, handleSubmit, formState, reset, setValue } = useForm< + const { register, handleSubmit, formState, watch, reset, setValue } = useForm< z.infer >({ - defaultValues: { - rating: 0, - }, + defaultValues: { rating: 0 }, resolver: zodResolver(CreateCommentValidationSchema), }); - const [rating, setRating] = useState(0); - useEffect(() => { - setRating(0); - reset({ rating: 0, content: '' }); - }, [reset]); - const onSubmit: SubmitHandler> = async ( data, ) => { - setValue('rating', 0); - setRating(0); await sendCreateBeerCommentRequest({ content: data.content, rating: data.rating, @@ -55,50 +39,15 @@ const BeerCommentForm: FunctionComponent = ({ reset(); }; - const { errors } = formState; - return ( -
-
- - Leave a comment - {errors.content?.message} - - - - - - Rating - {errors.rating?.message} - - { - setRating(value); - setValue('rating', value); - }} - > - - - - - - -
- -
- -
-
+ ); }; diff --git a/src/components/BreweryById/BreweryCommentsSection.tsx b/src/components/BreweryById/BreweryCommentsSection.tsx index 6b46355..15758fa 100644 --- a/src/components/BreweryById/BreweryCommentsSection.tsx +++ b/src/components/BreweryById/BreweryCommentsSection.tsx @@ -3,39 +3,118 @@ import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQuer import { FC, MutableRefObject, useContext, useRef } from 'react'; import { z } from 'zod'; import useBreweryPostComments from '@/hooks/useBreweryPostComments'; +import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult'; import LoadingComponent from '../BeerById/LoadingComponent'; import CommentsComponent from '../ui/CommentsComponent'; +import CommentForm from '../ui/CommentForm'; interface BreweryBeerSectionProps { breweryPost: z.infer; } -const BreweryCommentForm: FC = () => { - return null; +interface BreweryCommentFormProps { + breweryPost: z.infer; + mutate: ReturnType['mutate']; +} + +const BreweryCommentValidationSchemaWithId = CreateCommentValidationSchema.extend({ + breweryPostId: z.string(), +}); + +const sendCreateBreweryCommentRequest = async ({ + content, + rating, + breweryPostId, +}: z.infer) => { + const response = await fetch(`/api/breweries/${breweryPostId}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, rating }), + }); + + if (!response.ok) { + throw new Error(response.statusText); + } + + const data = await response.json(); + const parsedResponse = APIResponseValidationSchema.safeParse(data); + if (!parsedResponse.success) { + throw new Error('Invalid API response'); + } + + const parsedPayload = CommentQueryResult.safeParse(parsedResponse.data.payload); + if (!parsedPayload.success) { + throw new Error('Invalid API response payload'); + } + + return parsedPayload.data; +}; + +const BreweryCommentForm: FC = ({ breweryPost, mutate }) => { + const { register, handleSubmit, formState, watch, reset, setValue } = useForm< + z.infer + >({ + defaultValues: { rating: 0 }, + resolver: zodResolver(CreateCommentValidationSchema), + }); + + const onSubmit: SubmitHandler> = async ( + data, + ) => { + await sendCreateBreweryCommentRequest({ + content: data.content, + rating: data.rating, + breweryPostId: breweryPost.id, + }); + await mutate(); + reset(); + }; + + return ( + + ); }; const BreweryCommentsSection: FC = ({ breweryPost }) => { const { user } = useContext(UserContext); - const { id } = breweryPost; - const PAGE_SIZE = 4; - const { comments, isLoading, setSize, size, isLoadingMore, isAtEnd } = - useBreweryPostComments({ id, pageSize: PAGE_SIZE }); + const { + isLoading, + setSize, + size, + isLoadingMore, + isAtEnd, + mutate, + comments: breweryComments, + } = useBreweryPostComments({ id: breweryPost.id, pageSize: PAGE_SIZE }); const commentSectionRef: MutableRefObject = useRef(null); return (
- {user ? ( - - ) : ( -
-
Log in to leave a comment.
-
- )} +
+ {user ? ( + + ) : ( +
+
Log in to leave a comment.
+
+ )} +
{ /** @@ -48,7 +127,7 @@ const BreweryCommentsSection: FC = ({ breweryPost }) =>
) : ( = ({ latitude, longitude }) => { - const isDesktop = useMediaQuery('(min-width: 1024px)'); - const theme = - typeof window !== 'undefined' ? window.localStorage.getItem('theme') : 'dark'; - - const mapStyle = - theme === 'dark' - ? 'mapbox://styles/mapbox/dark-v11' - : 'mapbox://styles/mapbox/light-v10'; - return ( -
-
- - - -
-
- ); -}; - -export default BreweryMap; diff --git a/src/components/BreweryById/BreweryPostMap.tsx b/src/components/BreweryById/BreweryPostMap.tsx new file mode 100644 index 0000000..c880f15 --- /dev/null +++ b/src/components/BreweryById/BreweryPostMap.tsx @@ -0,0 +1,55 @@ +import useMediaQuery from '@/hooks/useMediaQuery'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import { FC, useMemo } from 'react'; +import Map, { Marker } from 'react-map-gl'; + +import LocationMarker from '../ui/LocationMarker'; + +interface BreweryMapProps { + latitude: number; + longitude: number; +} +type MapStyles = Record<'light' | 'dark', `mapbox://styles/mapbox/${string}`>; + +const BreweryPostMap: FC = ({ latitude, longitude }) => { + const isDesktop = useMediaQuery('(min-width: 1024px)'); + + const windowIsDefined = typeof window !== 'undefined'; + const themeIsDefined = windowIsDefined && !!window.localStorage.getItem('theme'); + + const theme = ( + windowIsDefined && themeIsDefined ? window.localStorage.getItem('theme') : 'light' + ) as 'light' | 'dark'; + + const pin = useMemo( + () => ( + + + + ), + [latitude, longitude], + ); + + const mapStyles: MapStyles = { + light: 'mapbox://styles/mapbox/light-v10', + dark: 'mapbox://styles/mapbox/dark-v11', + }; + + return ( +
+
+ + {pin} + +
+
+ ); +}; + +export default BreweryPostMap; diff --git a/src/components/ui/CommentForm.tsx b/src/components/ui/CommentForm.tsx new file mode 100644 index 0000000..f091cd6 --- /dev/null +++ b/src/components/ui/CommentForm.tsx @@ -0,0 +1,85 @@ +import { FC } from 'react'; +import { Rating } from 'react-daisyui'; +import type { + FormState, + SubmitHandler, + UseFormHandleSubmit, + UseFormRegister, + UseFormSetValue, + UseFormWatch, +} from 'react-hook-form'; +import FormError from './forms/FormError'; +import FormInfo from './forms/FormInfo'; +import FormLabel from './forms/FormLabel'; +import FormSegment from './forms/FormSegment'; +import FormTextArea from './forms/FormTextArea'; +import Button from './forms/Button'; + +interface Comment { + content: string; + rating: number; +} + +interface CommentFormProps { + handleSubmit: UseFormHandleSubmit; + onSubmit: SubmitHandler; + watch: UseFormWatch; + setValue: UseFormSetValue; + formState: FormState; + register: UseFormRegister; +} + +const CommentForm: FC = ({ + handleSubmit, + onSubmit, + watch, + setValue, + formState, + register, +}) => { + const { errors } = formState; + return ( +
+
+ + Leave a comment + {errors.content?.message} + + + + + + Rating + {errors.rating?.message} + + { + setValue('rating', value); + }} + > + + + + + + +
+ +
+ +
+
+ ); +}; + +export default CommentForm; diff --git a/src/components/ui/LocationMarker.tsx b/src/components/ui/LocationMarker.tsx new file mode 100644 index 0000000..a0c078a --- /dev/null +++ b/src/components/ui/LocationMarker.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { HiLocationMarker } from 'react-icons/hi'; + +const LocationMarker = () => { + return ; +}; + +export default React.memo(LocationMarker); diff --git a/src/pages/api/breweries/[id]/comments/index.ts b/src/pages/api/breweries/[id]/comments/index.ts index f3cc827..3daa44c 100644 --- a/src/pages/api/breweries/[id]/comments/index.ts +++ b/src/pages/api/breweries/[id]/comments/index.ts @@ -16,6 +16,7 @@ import { NextApiResponse } from 'next'; import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult'; import getAllBreweryComments from '@/services/BreweryComment/getAllBreweryComments'; import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema'; +import createNewBreweryComment from '@/services/BreweryComment/createNewBreweryComment'; interface CreateCommentRequest extends UserExtendedNextApiRequest { body: z.infer; @@ -26,29 +27,31 @@ interface GetAllCommentsRequest extends UserExtendedNextApiRequest { query: { id: string; page_size: string; page_num: string }; } -// const createComment = async ( -// req: CreateCommentRequest, -// res: NextApiResponse>, -// ) => { -// const { content, rating } = req.body; +const createComment = async ( + req: CreateCommentRequest, + res: NextApiResponse>, +) => { + const { content, rating } = req.body; -// const beerPostId = req.query.id; + const breweryPostId = req.query.id; -// const newBeerComment: z.infer = -// await createNewBeerComment({ -// content, -// rating, -// beerPostId, -// userId: req.user!.id, -// }); + const user = req.user!; -// res.status(201).json({ -// message: 'Beer comment created successfully', -// statusCode: 201, -// payload: newBeerComment, -// success: true, -// }); -// }; + const newBreweryComment: z.infer = + await createNewBreweryComment({ + content, + rating, + breweryPostId, + userId: user.id, + }); + + res.status(201).json({ + message: 'Beer comment created successfully', + statusCode: 201, + payload: newBreweryComment, + success: true, + }); +}; const getAll = async ( req: GetAllCommentsRequest, @@ -83,14 +86,14 @@ const router = createRouter< NextApiResponse> >(); -// router.post( -// validateRequest({ -// bodySchema: CreateBeerCommentValidationSchema, -// querySchema: z.object({ id: z.string().uuid() }), -// }), -// getCurrentUser, -// createComment, -// ); +router.post( + validateRequest({ + bodySchema: CreateCommentValidationSchema, + querySchema: z.object({ id: z.string().uuid() }), + }), + getCurrentUser, + createComment, +); router.get( validateRequest({ diff --git a/src/pages/beers/[id]/index.tsx b/src/pages/beers/[id]/index.tsx index d31c4cb..6cf7fa3 100644 --- a/src/pages/beers/[id]/index.tsx +++ b/src/pages/beers/[id]/index.tsx @@ -14,7 +14,7 @@ import { BeerPost } from '@prisma/client'; import { z } from 'zod'; -import 'react-responsive-carousel/lib/styles/carousel.min.css'; // requires a loader +import 'react-responsive-carousel/lib/styles/carousel.min.css'; import { Carousel } from 'react-responsive-carousel'; import useMediaQuery from '@/hooks/useMediaQuery'; import { Tab } from '@headlessui/react'; diff --git a/src/pages/breweries/[id].tsx b/src/pages/breweries/[id].tsx index 2c309b8..b9e0aba 100644 --- a/src/pages/breweries/[id].tsx +++ b/src/pages/breweries/[id].tsx @@ -1,7 +1,6 @@ import getBreweryPostById from '@/services/BreweryPost/getBreweryPostById'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; import { GetServerSideProps, NextPage } from 'next'; -import 'mapbox-gl/dist/mapbox-gl.css'; import { z } from 'zod'; import Head from 'next/head'; @@ -11,7 +10,7 @@ import { Carousel } from 'react-responsive-carousel'; import useMediaQuery from '@/hooks/useMediaQuery'; import { Tab } from '@headlessui/react'; import BreweryInfoHeader from '@/components/BreweryById/BreweryInfoHeader'; -import BreweryMap from '@/components/BreweryById/BreweryMap'; +import BreweryPostMap from '@/components/BreweryById/BreweryPostMap'; import BreweryBeersSection from '@/components/BreweryById/BreweryBeerSection.tsx'; import BreweryCommentsSection from '@/components/BreweryById/BreweryCommentsSection'; @@ -63,13 +62,13 @@ const BreweryByIdPage: NextPage = ({ breweryPost }) => {
- +
) : ( <> - + diff --git a/src/pages/breweries/map.tsx b/src/pages/breweries/map.tsx new file mode 100644 index 0000000..b20b9c7 --- /dev/null +++ b/src/pages/breweries/map.tsx @@ -0,0 +1,127 @@ +import { GetServerSideProps, NextPage } from 'next'; +import { useMemo, useState } from 'react'; +import Map, { + FullscreenControl, + Marker, + NavigationControl, + Popup, + ScaleControl, +} from 'react-map-gl'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import DBClient from '@/prisma/DBClient'; + +import LocationMarker from '@/components/ui/LocationMarker'; +import Link from 'next/link'; + +type MapStyles = Record<'light' | 'dark', `mapbox://styles/mapbox/${string}`>; + +interface BreweryMapPageProps { + breweries: { + location: { + city: string; + stateOrProvince: string | null; + country: string | null; + coordinates: number[]; + }; + id: string; + name: string; + }[]; +} + +const BreweryMapPage: NextPage = ({ breweries }) => { + const windowIsDefined = typeof window !== 'undefined'; + const themeIsDefined = windowIsDefined && !!window.localStorage.getItem('theme'); + + const [popupInfo, setPopupInfo] = useState( + null, + ); + + const theme = ( + windowIsDefined && themeIsDefined ? window.localStorage.getItem('theme') : 'light' + ) as 'light' | 'dark'; + + const mapStyles: MapStyles = { + light: 'mapbox://styles/mapbox/light-v10', + dark: 'mapbox://styles/mapbox/dark-v11', + }; + + const pins = useMemo( + () => ( + <> + {breweries.map((brewery) => { + const [longitude, latitude] = brewery.location.coordinates; + return ( + { + e.originalEvent.stopPropagation(); + setPopupInfo(brewery); + }} + > + + + ); + })} + + ), + [breweries], + ); + return ( +
+ + + + + {pins} + {popupInfo && ( + setPopupInfo(null)} + > +
+ + {popupInfo.name} + +

+ {popupInfo.location.city} + {popupInfo.location.stateOrProvince + ? `, ${popupInfo.location.stateOrProvince}` + : ''} + {popupInfo.location.country ? `, ${popupInfo.location.country}` : ''} +

+
+
+ )} +
+
+ ); +}; + +export default BreweryMapPage; + +export const getServerSideProps: GetServerSideProps = async () => { + const breweries = await DBClient.instance.breweryPost.findMany({ + select: { + location: { + select: { coordinates: true, city: true, country: true, stateOrProvince: true }, + }, + id: true, + name: true, + }, + }); + + return { props: { breweries } }; +}; diff --git a/src/requests/sendBreweryPostLikeRequest.ts b/src/requests/sendBreweryPostLikeRequest.ts index 47cfee2..be400b3 100644 --- a/src/requests/sendBreweryPostLikeRequest.ts +++ b/src/requests/sendBreweryPostLikeRequest.ts @@ -8,18 +8,11 @@ const sendBreweryPostLikeRequest = async (breweryPostId: string) => { const json = await response.json(); const parsed = APIResponseValidationSchema.safeParse(json); - if (!parsed.success) { throw new Error('Invalid API response.'); } - if (!parsed.success) { - throw new Error('Invalid API response.'); - } - - const { success, message } = parsed.data; - - return { success, message }; + return parsed.data; }; export default sendBreweryPostLikeRequest; diff --git a/src/requests/sendCreateBeerCommentRequest.ts b/src/requests/sendCreateBeerCommentRequest.ts index 230d43d..afa8a2c 100644 --- a/src/requests/sendCreateBeerCommentRequest.ts +++ b/src/requests/sendCreateBeerCommentRequest.ts @@ -18,13 +18,12 @@ const sendCreateBeerCommentRequest = async ({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ beerPostId, content, rating }), }); + if (!response.ok) { + throw new Error(response.statusText); + } const data = await response.json(); - if (!response.ok) { - throw new Error(data.message); - } - const parsedResponse = APIResponseValidationSchema.safeParse(data); if (!parsedResponse.success) { diff --git a/src/services/BreweryComment/createNewBreweryComment.ts b/src/services/BreweryComment/createNewBreweryComment.ts new file mode 100644 index 0000000..9dc1ed6 --- /dev/null +++ b/src/services/BreweryComment/createNewBreweryComment.ts @@ -0,0 +1,33 @@ +import DBClient from '@/prisma/DBClient'; +import { z } from 'zod'; +import CreateCommentValidationSchema from '../types/CommentSchema/CreateCommentValidationSchema'; + +const CreateNewBreweryCommentServiceSchema = CreateCommentValidationSchema.extend({ + userId: z.string().uuid(), + breweryPostId: z.string().uuid(), +}); + +const createNewBreweryComment = async ({ + content, + rating, + breweryPostId, + userId, +}: z.infer) => { + return DBClient.instance.breweryComment.create({ + data: { + content, + rating, + breweryPost: { connect: { id: breweryPostId } }, + postedBy: { connect: { id: userId } }, + }, + select: { + id: true, + content: true, + rating: true, + postedBy: { select: { id: true, username: true } }, + createdAt: true, + }, + }); +}; + +export default createNewBreweryComment;