diff --git a/src/components/BeerIndex/BeerCard.tsx b/src/components/BeerIndex/BeerCard.tsx index 620af43..d1c3d77 100644 --- a/src/components/BeerIndex/BeerCard.tsx +++ b/src/components/BeerIndex/BeerCard.tsx @@ -51,7 +51,9 @@ const BeerCard: FC<{ post: z.infer }> = ({ post }) = )}
- {!!user && } + {!!user && !isLoading && ( + + )}
diff --git a/src/components/BreweryById/BreweryPostMap.tsx b/src/components/BreweryById/BreweryPostMap.tsx index 2543122..44295be 100644 --- a/src/components/BreweryById/BreweryPostMap.tsx +++ b/src/components/BreweryById/BreweryPostMap.tsx @@ -4,6 +4,7 @@ import { FC, useMemo } from 'react'; import Map, { Marker } from 'react-map-gl'; import LocationMarker from '../ui/LocationMarker'; +import ControlPanel from '../ui/maps/ControlPanel'; interface BreweryMapProps { latitude: number; @@ -45,6 +46,7 @@ const BreweryPostMap: FC = ({ latitude, longitude }) => { mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN as string} scrollZoom > + {pin} diff --git a/src/components/BreweryIndex/BreweryCard.tsx b/src/components/BreweryIndex/BreweryCard.tsx index 63cc934..bded816 100644 --- a/src/components/BreweryIndex/BreweryCard.tsx +++ b/src/components/BreweryIndex/BreweryCard.tsx @@ -41,7 +41,7 @@ const BreweryCard: FC<{ brewery: z.infer }> = ({
{!isLoading && liked by {likeCount} users} - {user && ( + {!!user && !isLoading && ( )}
diff --git a/src/components/ui/LocationMarker.tsx b/src/components/ui/LocationMarker.tsx index 1688288..ba394c6 100644 --- a/src/components/ui/LocationMarker.tsx +++ b/src/components/ui/LocationMarker.tsx @@ -7,14 +7,14 @@ interface LocationMarkerProps { } const sizeClasses: Record, `text-${string}`> = { - sm: 'text-2xl', - md: 'text-3xl', - lg: 'text-4xl', - xl: 'text-5xl', + sm: 'text-lg', + md: 'text-xl', + lg: 'text-2xl', + xl: 'text-3xl', }; const LocationMarker: FC = ({ size = 'md', color = 'blue' }) => { - return ; + return ; }; export default React.memo(LocationMarker); diff --git a/src/components/ui/maps/ControlPanel.tsx b/src/components/ui/maps/ControlPanel.tsx new file mode 100644 index 0000000..cb422f6 --- /dev/null +++ b/src/components/ui/maps/ControlPanel.tsx @@ -0,0 +1,12 @@ +import { FC, memo } from 'react'; +import { FullscreenControl, NavigationControl, ScaleControl } from 'react-map-gl'; + +const ControlPanel: FC = () => ( + <> + + + + +); + +export default memo(ControlPanel); diff --git a/src/hooks/data-fetching/brewery-posts/useBreweryMapPagePosts.ts b/src/hooks/data-fetching/brewery-posts/useBreweryMapPagePosts.ts new file mode 100644 index 0000000..ca91166 --- /dev/null +++ b/src/hooks/data-fetching/brewery-posts/useBreweryMapPagePosts.ts @@ -0,0 +1,58 @@ +import BreweryPostMapQueryResult from '@/services/BreweryPost/types/BreweryPostMapQueryResult'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWRInfinite from 'swr/infinite'; +import { z } from 'zod'; + +const useBreweryMapPagePosts = ({ pageSize }: { pageSize: number }) => { + 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(BreweryPostMapQueryResult) + .safeParse(parsed.data.payload); + if (!parsedPayload.success) { + throw new Error('API payload validation failed'); + } + + const pageCount = Math.ceil((count as string, 10) / pageSize); + + return { breweryPosts: parsedPayload.data, pageCount }; + }; + + const { data, error, isLoading, setSize, size } = useSWRInfinite( + (index) => `/api/breweries/map?page_num=${index + 1}&page_size=${pageSize}`, + fetcher, + ); + + 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 { + breweries: breweryPosts, + isLoading, + isLoadingMore, + isAtEnd, + size, + setSize, + pageCount, + error: error as unknown, + }; +}; + +export default useBreweryMapPagePosts; diff --git a/src/pages/api/breweries/map/index.ts b/src/pages/api/breweries/map/index.ts new file mode 100644 index 0000000..5a75e0e --- /dev/null +++ b/src/pages/api/breweries/map/index.ts @@ -0,0 +1,68 @@ +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import DBClient from '@/prisma/DBClient'; +import BreweryPostMapQueryResult from '@/services/BreweryPost/types/BreweryPostMapQueryResult'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface GetBreweryPostsRequest extends NextApiRequest { + query: { + page_num: string; + page_size: string; + }; +} + +const getBreweryPosts = async ( + req: GetBreweryPostsRequest, + res: NextApiResponse>, +) => { + const pageNum = parseInt(req.query.page_num, 10); + const pageSize = parseInt(req.query.page_size, 10); + + const skip = (pageNum - 1) * pageSize; + const take = pageSize; + + const breweryPosts: z.infer[] = + await DBClient.instance.breweryPost.findMany({ + select: { + location: { + select: { coordinates: true, city: true, country: true, stateOrProvince: true }, + }, + id: true, + name: true, + }, + skip, + take, + }); + const breweryPostCount = await DBClient.instance.breweryPost.count(); + + res.setHeader('X-Total-Count', breweryPostCount); + + res.status(200).json({ + message: 'Brewery posts retrieved successfully', + statusCode: 200, + payload: breweryPosts, + success: true, + }); +}; + +const router = createRouter< + GetBreweryPostsRequest, + NextApiResponse> +>(); + +router.get( + validateRequest({ + querySchema: z.object({ + page_num: z.string().regex(/^\d+$/), + page_size: z.string().regex(/^\d+$/), + }), + }), + getBreweryPosts, +); + +const handler = router.handler(); + +export default handler; diff --git a/src/pages/breweries/map.tsx b/src/pages/breweries/map.tsx index cfa5854..826a62a 100644 --- a/src/pages/breweries/map.tsx +++ b/src/pages/breweries/map.tsx @@ -1,46 +1,31 @@ -import { GetServerSideProps, NextPage } from 'next'; -import { useMemo, useState } from 'react'; -import Map, { - FullscreenControl, - Marker, - NavigationControl, - Popup, - ScaleControl, -} from 'react-map-gl'; +import { NextPage } from 'next'; +import { useEffect, useMemo, useState } from 'react'; +import Map, { Marker, Popup } 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'; import Head from 'next/head'; import useGeolocation from '@/hooks/utilities/useGeolocation'; +import BreweryPostMapQueryResult from '@/services/BreweryPost/types/BreweryPostMapQueryResult'; +import { z } from 'zod'; +import useBreweryMapPagePosts from '@/hooks/data-fetching/brewery-posts/useBreweryMapPagePosts'; +import ControlPanel from '@/components/ui/maps/ControlPanel'; 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 = () => { + const [popupInfo, setPopupInfo] = useState | null>(null); -const BreweryMapPage: NextPage = ({ breweries }) => { - const windowIsDefined = typeof window !== 'undefined'; - const themeIsDefined = windowIsDefined && !!window.localStorage.getItem('theme'); + const [theme, setTheme] = useState<'light' | 'dark'>('light'); - const [popupInfo, setPopupInfo] = useState( - null, - ); + useEffect(() => { + setTheme(localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'); + }, []); - const theme = ( - windowIsDefined && themeIsDefined ? window.localStorage.getItem('theme') : 'light' - ) as 'light' | 'dark'; + const { breweries } = useBreweryMapPagePosts({ pageSize: 50 }); const mapStyles: MapStyles = { light: 'mapbox://styles/mapbox/light-v10', @@ -52,11 +37,12 @@ const BreweryMapPage: NextPage = ({ breweries }) => { <> {breweries.map((brewery) => { const [longitude, latitude] = brewery.location.coordinates; + return ( { e.originalEvent.stopPropagation(); setPopupInfo(brewery); @@ -71,16 +57,16 @@ const BreweryMapPage: NextPage = ({ breweries }) => { [breweries], ); - const { coords, error } = useGeolocation(); + const { coords, error: geoError } = useGeolocation(); const userLocationPin = useMemo( () => - coords && !error ? ( + coords && !geoError ? ( ) : null, - [coords, error], + [coords, geoError], ); return ( @@ -94,15 +80,14 @@ const BreweryMapPage: NextPage = ({ breweries }) => {
- - - + {pins} {userLocationPin} {popupInfo && ( @@ -112,7 +97,7 @@ const BreweryMapPage: NextPage = ({ breweries }) => { latitude={popupInfo.location.coordinates[1]} onClose={() => setPopupInfo(null)} > -
+
= ({ breweries }) => { }; 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/services/BreweryPost/types/BreweryPostMapQueryResult.ts b/src/services/BreweryPost/types/BreweryPostMapQueryResult.ts new file mode 100644 index 0000000..e0c9cf5 --- /dev/null +++ b/src/services/BreweryPost/types/BreweryPostMapQueryResult.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +const BreweryPostMapQueryResult = z.object({ + location: z.object({ + coordinates: z.array(z.number()), + city: z.string(), + country: z.string().nullable(), + stateOrProvince: z.string().nullable(), + }), + id: z.string(), + name: z.string(), +}); + +export default BreweryPostMapQueryResult;