diff --git a/src/components/BeerById/BeerRecommendationLoadingComponent.tsx b/src/components/BeerById/BeerRecommendationLoadingComponent.tsx new file mode 100644 index 0000000..c90f110 --- /dev/null +++ b/src/components/BeerById/BeerRecommendationLoadingComponent.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; +import Spinner from '../ui/Spinner'; + +interface BeerRecommendationLoadingComponentProps { + length: number; +} + +const BeerRecommendationLoadingComponent: FC = ({ + length, +}) => { + return ( + <> + {Array.from({ length }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ +
+ + ); +}; + +export default BeerRecommendationLoadingComponent; diff --git a/src/components/BreweryById/BreweryBeerSection.tsx b/src/components/BreweryById/BreweryBeerSection.tsx new file mode 100644 index 0000000..bb4b0e4 --- /dev/null +++ b/src/components/BreweryById/BreweryBeerSection.tsx @@ -0,0 +1,84 @@ +import UseBeerPostsByBrewery from '@/hooks/useBeerPostsByBrewery'; +import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; +import Link from 'next/link'; +import { FC } from 'react'; +import { useInView } from 'react-intersection-observer'; +import { z } from 'zod'; +import BeerRecommendationLoadingComponent from '../BeerById/BeerRecommendationLoadingComponent'; + +interface BreweryCommentsSectionProps { + breweryPost: z.infer; +} + +const BreweryBeersSection: FC = ({ breweryPost }) => { + const PAGE_SIZE = 2; + const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = UseBeerPostsByBrewery({ + breweryId: breweryPost.id, + 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; + setSize(size + 1); + }, + }); + + return ( +
+
+ <> +

Brews

+ {!!beerPosts.length && ( +
+ {beerPosts.map((beerPost, 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 ( +
+
+ + {beerPost.name} + +
+ +
+ {beerPost.type.name} +
+
+ {beerPost.abv}% ABV + {beerPost.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 BreweryBeersSection; diff --git a/src/components/BreweryById/BreweryBeerSection.tsx.tsx b/src/components/BreweryById/BreweryBeerSection.tsx.tsx deleted file mode 100644 index 0cbd7ff..0000000 --- a/src/components/BreweryById/BreweryBeerSection.tsx.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { FC } from 'react'; - -interface BreweryCommentsSectionProps {} - -const BreweryBeersSection: FC = () => { - return
; -}; - -export default BreweryBeersSection; diff --git a/src/components/ui/CommentsComponent.tsx b/src/components/ui/CommentsComponent.tsx index 01501c9..6796372 100644 --- a/src/components/ui/CommentsComponent.tsx +++ b/src/components/ui/CommentsComponent.tsx @@ -36,10 +36,10 @@ const CommentsComponent: FC = ({ setSize, size, }) => { - const { ref: lastCommentRef } = useInView({ + const { ref: penultimateCommentRef } = useInView({ /** - * When the last comment comes into view, call setSize from useBeerPostComments to - * load more comments. + * When the second last comment comes into view, call setSize from useBeerPostComments + * to load more comments. */ onChange: (visible) => { if (!visible || isAtEnd) return; @@ -52,7 +52,7 @@ const CommentsComponent: FC = ({ {!!comments.length && (
{comments.map((comment, index) => { - const isPenulitmateComment = index === comments.length - 2; + const isPenultimateComment = index === comments.length - 2; /** * Attach a ref to the last comment in the list. When it comes into view, the @@ -60,7 +60,7 @@ const CommentsComponent: FC = ({ */ return (
diff --git a/src/components/ui/LocationMarker.tsx b/src/components/ui/LocationMarker.tsx index a0c078a..1688288 100644 --- a/src/components/ui/LocationMarker.tsx +++ b/src/components/ui/LocationMarker.tsx @@ -1,8 +1,20 @@ -import React from 'react'; +import React, { FC } from 'react'; import { HiLocationMarker } from 'react-icons/hi'; -const LocationMarker = () => { - return ; +interface LocationMarkerProps { + size?: 'sm' | 'md' | 'lg' | 'xl'; + color?: 'blue' | 'red' | 'green' | 'yellow'; +} + +const sizeClasses: Record, `text-${string}`> = { + sm: 'text-2xl', + md: 'text-3xl', + lg: 'text-4xl', + xl: 'text-5xl', +}; + +const LocationMarker: FC = ({ size = 'md', color = 'blue' }) => { + return ; }; export default React.memo(LocationMarker); diff --git a/src/hooks/useBeerPostsByBrewery.ts b/src/hooks/useBeerPostsByBrewery.ts new file mode 100644 index 0000000..c0762cb --- /dev/null +++ b/src/hooks/useBeerPostsByBrewery.ts @@ -0,0 +1,69 @@ +import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWRInfinite from 'swr/infinite'; +import { z } from 'zod'; + +interface UseBeerPostsByBreweryParams { + pageSize: number; + breweryId: string; +} + +/** + * A custom hook using SWR to fetch beer posts from the API. + * + * @param options The options to use when fetching beer posts. + * @param options.pageSize The number of beer posts to fetch per page. + * @param options.breweryId The ID of the brewery to fetch beer posts for. + * @returns An object containing the beer posts, page count, and loading state. + */ +const UseBeerPostsByBrewery = ({ pageSize, breweryId }: UseBeerPostsByBreweryParams) => { + 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/breweries/${breweryId}/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 UseBeerPostsByBrewery; diff --git a/src/hooks/useGeolocation.ts b/src/hooks/useGeolocation.ts new file mode 100644 index 0000000..277d248 --- /dev/null +++ b/src/hooks/useGeolocation.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; + +/** + * A custom React Hook that retrieves and monitors the user's geolocation using the + * browser's built-in `navigator.geolocation` API. + * + * @returns An object containing the user's geolocation information and any errors that + * might occur. The object has the following properties: + * + * - `coords` - The user's current geolocation coordinates, or null if the geolocation could + * not be retrieved. + * - `timestamp` - The timestamp when the user's geolocation was last updated, or null if + * the geolocation could not be retrieved. + * - `error` - Any error that might occur while retrieving or monitoring the user's + * geolocation, or null if there are no errors. + */ +const useGeolocation = () => { + const [state, setState] = useState<{ + coords: GeolocationCoordinates | null; + timestamp: number | null; + }>({ + coords: null, + timestamp: null, + }); + + const [error, setError] = useState(null); + + // Set up the event listeners for the geolocation updates + useEffect(() => { + /** + * Callback function for successful geolocation update. + * + * @param position - The geolocation position object. + */ + const onEvent = (position: GeolocationPosition) => { + const { coords, timestamp } = position; + setError(null); + setState({ coords, timestamp }); + }; + + /** + * Callback function for geolocation error. + * + * @param geoError - The geolocation error object. + */ + const onError = (geoError: GeolocationPositionError) => { + setError(geoError); + }; + + // Get the current geolocation + navigator.geolocation.getCurrentPosition(onEvent, onError); + + // Monitor any changes in the geolocation + const watchId = navigator.geolocation.watchPosition(onEvent, onError); + + // Clean up the event listeners when the component unmounts + return () => { + navigator.geolocation.clearWatch(watchId); + }; + }, []); + + // Return the geolocation information and any errors as an object + return { coords: state.coords, timestamp: state.timestamp, error }; +}; + +export default useGeolocation; diff --git a/src/pages/api/breweries/[id]/beers/index.ts b/src/pages/api/breweries/[id]/beers/index.ts new file mode 100644 index 0000000..aa1f0b3 --- /dev/null +++ b/src/pages/api/breweries/[id]/beers/index.ts @@ -0,0 +1,72 @@ +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import DBClient from '@/prisma/DBClient'; +import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface GetAllBeersByBreweryRequest extends NextApiRequest { + query: { page_size: string; page_num: string; id: string }; +} + +const getAllBeersByBrewery = async ( + req: GetAllBeersByBreweryRequest, + res: NextApiResponse>, +) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { page_size, page_num, id } = req.query; + + const beers: z.infer[] = + await DBClient.instance.beerPost.findMany({ + where: { breweryId: id }, + take: parseInt(page_size, 10), + skip: parseInt(page_num, 10) * parseInt(page_size, 10), + 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 } }, + }, + }); + + const pageCount = await DBClient.instance.beerPost.count({ + where: { breweryId: id }, + }); + + res.setHeader('X-Total-Count', pageCount); + + res.status(200).json({ + message: 'Beers fetched successfully', + statusCode: 200, + payload: beers, + success: true, + }); +}; + +const router = createRouter< + GetAllBeersByBreweryRequest, + NextApiResponse> +>(); + +router.get( + validateRequest({ + querySchema: z.object({ + page_size: z.string().nonempty(), + page_num: z.string().nonempty(), + id: z.string().nonempty(), + }), + }), + getAllBeersByBrewery, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/breweries/[id].tsx b/src/pages/breweries/[id].tsx index b9e0aba..351ae30 100644 --- a/src/pages/breweries/[id].tsx +++ b/src/pages/breweries/[id].tsx @@ -11,7 +11,7 @@ import useMediaQuery from '@/hooks/useMediaQuery'; import { Tab } from '@headlessui/react'; import BreweryInfoHeader from '@/components/BreweryById/BreweryInfoHeader'; import BreweryPostMap from '@/components/BreweryById/BreweryPostMap'; -import BreweryBeersSection from '@/components/BreweryById/BreweryBeerSection.tsx'; +import BreweryBeersSection from '@/components/BreweryById/BreweryBeerSection'; import BreweryCommentsSection from '@/components/BreweryById/BreweryCommentsSection'; interface BreweryPageProps { @@ -63,7 +63,7 @@ const BreweryByIdPage: NextPage = ({ breweryPost }) => {
- +
) : ( @@ -83,7 +83,7 @@ const BreweryByIdPage: NextPage = ({ breweryPost }) => { - + diff --git a/src/pages/breweries/map.tsx b/src/pages/breweries/map.tsx index 15147f6..90d85c1 100644 --- a/src/pages/breweries/map.tsx +++ b/src/pages/breweries/map.tsx @@ -13,6 +13,7 @@ import DBClient from '@/prisma/DBClient'; import LocationMarker from '@/components/ui/LocationMarker'; import Link from 'next/link'; import Head from 'next/head'; +import useGeolocation from '@/hooks/useGeolocation'; type MapStyles = Record<'light' | 'dark', `mapbox://styles/mapbox/${string}`>; @@ -61,7 +62,7 @@ const BreweryMapPage: NextPage = ({ breweries }) => { setPopupInfo(brewery); }} > - + ); })} @@ -69,6 +70,19 @@ const BreweryMapPage: NextPage = ({ breweries }) => { ), [breweries], ); + + const { coords, error } = useGeolocation(); + + const userLocationPin = useMemo( + () => + coords && !error ? ( + + + + ) : null, + [coords, error], + ); + return ( <> @@ -90,6 +104,7 @@ const BreweryMapPage: NextPage = ({ breweries }) => { {pins} + {userLocationPin} {popupInfo && (