Feat: Implement mapbox for geocoding and location data for brewery posts

This commit is contained in:
Aaron William Po
2023-04-24 21:45:11 -04:00
parent eec082e73a
commit 4aeafc0de8
17 changed files with 1845 additions and 134 deletions

View File

@@ -10,9 +10,11 @@ import useGetBeerPostLikeCount from '@/hooks/useBeerPostLikeCount';
import useTimeDistance from '@/hooks/useTimeDistance';
import BeerPostLikeButton from './BeerPostLikeButton';
const BeerInfoHeader: FC<{
interface BeerInfoHeaderProps {
beerPost: z.infer<typeof beerPostQueryResult>;
}> = ({ beerPost }) => {
}
const BeerInfoHeader: FC<BeerInfoHeaderProps> = ({ beerPost }) => {
const createdAt = new Date(beerPost.createdAt);
const timeDistance = useTimeDistance(createdAt);
@@ -23,21 +25,40 @@ const BeerInfoHeader: FC<{
const { likeCount, mutate } = useGetBeerPostLikeCount(beerPost.id);
return (
<main className="card flex flex-col justify-center bg-base-300">
<article className="card-body">
<div className="flex justify-between">
<header>
<h1 className="text-2xl font-bold lg:text-4xl">{beerPost.name}</h1>
<h2 className="text-lg font-semibold lg:text-2xl">
by{' '}
<Link
href={`/breweries/${beerPost.brewery.id}`}
className="link-hover link font-semibold"
>
{beerPost.brewery.name}
</Link>
</h2>
</header>
<article className="card flex flex-col justify-center bg-base-300">
<div className="card-body">
<header className="flex justify-between">
<div className="space-y-2">
<div>
<h1 className="text-2xl font-bold lg:text-4xl">{beerPost.name}</h1>
<h2 className="text-lg font-semibold lg:text-2xl">
by{' '}
<Link
href={`/breweries/${beerPost.brewery.id}`}
className="link-hover link font-semibold"
>
{beerPost.brewery.name}
</Link>
</h2>
</div>
<div>
<h3 className="italic">
{' posted by '}
<Link href={`/users/${beerPost.postedBy.id}`} className="link-hover link">
{`${beerPost.postedBy.username} `}
</Link>
{timeDistance && (
<span
className="tooltip tooltip-right"
data-tip={format(createdAt, 'MM/dd/yyyy')}
>
{`${timeDistance} ago`}
</span>
)}
</h3>
</div>
</div>
{isPostOwner && (
<div className="tooltip tooltip-left" data-tip={`Edit '${beerPost.name}'`}>
<Link href={`/beers/${beerPost.id}/edit`} className="btn-ghost btn-xs btn">
@@ -45,52 +66,40 @@ const BeerInfoHeader: FC<{
</Link>
</div>
)}
</div>
<h3 className="italic">
{' posted by '}
<Link href={`/users/${beerPost.postedBy.id}`} className="link-hover link">
{`${beerPost.postedBy.username} `}
</Link>
{timeDistance && (
<span
className="tooltip tooltip-right"
data-tip={format(createdAt, 'MM/dd/yyyy')}
>
{`${timeDistance} ago`}
</span>
)}
</h3>
<p>{beerPost.description}</p>
<div className="flex justify-between">
<div className="space-y-1">
<div>
<Link
className="link-hover link text-lg font-bold"
href={`/beers/types/${beerPost.type.id}`}
>
{beerPost.type.name}
</Link>
</header>
<div className="space-y-2">
<p>{beerPost.description}</p>
<div className="flex justify-between">
<div className="space-y-1">
<div>
<Link
className="link-hover link text-lg font-bold"
href={`/beers/types/${beerPost.type.id}`}
>
{beerPost.type.name}
</Link>
</div>
<div>
<span className="mr-4 text-lg font-medium">{beerPost.abv}% ABV</span>
<span className="text-lg font-medium">{beerPost.ibu} IBU</span>
</div>
<div>
{(!!likeCount || likeCount === 0) && (
<span>
Liked by {likeCount} user{likeCount !== 1 && 's'}
</span>
)}
</div>
</div>
<div>
<span className="mr-4 text-lg font-medium">{beerPost.abv}% ABV</span>
<span className="text-lg font-medium">{beerPost.ibu} IBU</span>
</div>
<div>
{(!!likeCount || likeCount === 0) && (
<span>
Liked by {likeCount} user{likeCount !== 1 && 's'}
</span>
<div className="card-actions items-end">
{user && (
<BeerPostLikeButton beerPostId={beerPost.id} mutateCount={mutate} />
)}
</div>
</div>
<div className="card-actions items-end">
{user && <BeerPostLikeButton beerPostId={beerPost.id} mutateCount={mutate} />}
</div>
</div>
</article>
</main>
</div>
</article>
);
};

View File

@@ -37,16 +37,16 @@ const BeerCard: FC<{ post: z.infer<typeof beerPostQueryResult> }> = ({ post }) =
</h4>
</Link>
</div>
<div>
<div className="flex items-end justify-between">
<div>
<p className="text-md lg:text-xl">{post.type.name}</p>
<div className="space-x-3">
<span className="text-sm lg:text-lg">{post.abv}% ABV</span>
<span className="text-sm lg:text-lg">{post.ibu} IBU</span>
</div>
</div>
<div className="flex justify-between">
<span>liked by {likeCount} users</span>
</div>
<div>
{user && <BeerPostLikeButton beerPostId={post.id} mutateCount={mutate} />}
</div>
</div>

View File

@@ -32,7 +32,7 @@ const BreweryCard: FC<{ brewery: z.infer<typeof BreweryPostQueryResult> }> = ({
</Link>
</h2>
<h3 className="text-xl font-normal lg:text-2xl">
located in {brewery.location}
located in {brewery.city}, {brewery.stateOrProvince || brewery.country}
</h3>
<h4 className="text-lg lg:text-xl">
est. {brewery.dateEstablished.getFullYear()}

View File

@@ -22,6 +22,7 @@ const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
SPARKPOST_API_KEY: z.string(),
SPARKPOST_SENDER_ADDRESS: z.string().email(),
MAPBOX_ACCESS_TOKEN: z.string()
});
const parsed = envSchema.safeParse(env);
@@ -145,3 +146,14 @@ export const SPARKPOST_API_KEY = parsed.data.SPARKPOST_API_KEY;
* @see https://app.sparkpost.com/domains/list/sending
*/
export const SPARKPOST_SENDER_ADDRESS = parsed.data.SPARKPOST_SENDER_ADDRESS;
/**
* Your Mapbox access token.
*
* @example
* 'pk.abcdefghijklmnopqrstuvwxyz123456';
*
* @see https://docs.mapbox.com/help/how-mapbox-works/access-tokens/
*/
export const MAPBOX_ACCESS_TOKEN = parsed.data.MAPBOX_ACCESS_TOKEN;

View File

@@ -0,0 +1,12 @@
import mbxGeocoding from '@mapbox/mapbox-sdk/services/geocoding';
import { MAPBOX_ACCESS_TOKEN } from '../env';
const geocoder = mbxGeocoding({ accessToken: MAPBOX_ACCESS_TOKEN });
const geocode = async (query: string) => {
const geoData = await geocoder.forwardGeocode({ query, limit: 1 }).send();
return geoData.body.features[0];
};
export default geocode;

View File

@@ -37,68 +37,66 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({ beerPost, beerRecommendations }
<meta name="description" content={beerPost.description} />
</Head>
<>
<div>
<Carousel
className="w-full"
useKeyboardArrows
autoPlay
interval={10000}
infiniteLoop
showThumbs={false}
>
{beerPost.beerImages.length
? beerPost.beerImages.map((image, index) => (
<div key={image.id} id={`image-${index}}`} className="w-full">
<Image
alt={image.alt}
src={image.path}
height={1080}
width={1920}
className="h-96 w-full object-cover lg:h-[42rem]"
/>
</div>
))
: Array.from({ length: 1 }).map((_, i) => (
<div className="h-96 lg:h-[42rem]" key={i} />
))}
</Carousel>
<div className="mb-12 mt-10 flex w-full items-center justify-center ">
<div className="w-11/12 space-y-3 xl:w-9/12 2xl:w-8/12">
<BeerInfoHeader beerPost={beerPost} />
{isDesktop ? (
<div className="mt-4 flex flex-row space-x-3 space-y-0">
<div className="w-[60%]">
<BeerPostCommentsSection beerPost={beerPost} />
</div>
<div className="w-[40%]">
<BeerRecommendations beerRecommendations={beerRecommendations} />
</div>
<Carousel
className="w-full"
useKeyboardArrows
autoPlay
interval={10000}
infiniteLoop
showThumbs={false}
>
{beerPost.beerImages.length
? beerPost.beerImages.map((image, index) => (
<div key={image.id} id={`image-${index}}`} className="w-full">
<Image
alt={image.alt}
src={image.path}
height={1080}
width={1920}
className="h-96 w-full object-cover lg:h-[42rem]"
/>
</div>
) : (
<Tab.Group>
<Tab.List className="tabs tabs-boxed items-center justify-center rounded-2xl">
<Tab className="tab tab-md w-1/2 uppercase ui-selected:tab-active">
Comments
</Tab>
<Tab className="tab tab-md w-1/2 uppercase ui-selected:tab-active">
Other Beers
</Tab>
</Tab.List>
<Tab.Panels className="mt-2">
<Tab.Panel>
<BeerPostCommentsSection beerPost={beerPost} />
</Tab.Panel>
<Tab.Panel>
<BeerRecommendations beerRecommendations={beerRecommendations} />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
)}
</div>
))
: Array.from({ length: 1 }).map((_, i) => (
<div className="h-96 lg:h-[42rem]" key={i} />
))}
</Carousel>
<main className="mb-12 mt-10 flex w-full items-center justify-center">
<div className="w-11/12 space-y-3 xl:w-9/12 2xl:w-8/12">
<BeerInfoHeader beerPost={beerPost} />
{isDesktop ? (
<div className="mt-4 flex flex-row space-x-3 space-y-0">
<div className="w-[60%]">
<BeerPostCommentsSection beerPost={beerPost} />
</div>
<div className="w-[40%]">
<BeerRecommendations beerRecommendations={beerRecommendations} />
</div>
</div>
) : (
<Tab.Group>
<Tab.List className="tabs tabs-boxed items-center justify-center rounded-2xl">
<Tab className="tab tab-md w-1/2 uppercase ui-selected:tab-active">
Comments
</Tab>
<Tab className="tab tab-md w-1/2 uppercase ui-selected:tab-active">
Other Beers
</Tab>
</Tab.List>
<Tab.Panels className="mt-2">
<Tab.Panel>
<BeerPostCommentsSection beerPost={beerPost} />
</Tab.Panel>
<Tab.Panel>
<BeerRecommendations beerRecommendations={beerRecommendations} />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
)}
</div>
</div>
</main>
</>
</>
);

View File

@@ -1,16 +1,176 @@
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 MapGL, { Marker } from 'react-map-gl';
import { z } from 'zod';
import { FC, useContext } from 'react';
import Head from 'next/head';
import Image from 'next/image';
import 'react-responsive-carousel/lib/styles/carousel.min.css'; // requires a loader
import { Carousel } from 'react-responsive-carousel';
import useGetBreweryPostLikeCount from '@/hooks/useGetBreweryPostLikeCount';
import useTimeDistance from '@/hooks/useTimeDistance';
import UserContext from '@/contexts/userContext';
import Link from 'next/link';
import { FaRegEdit } from 'react-icons/fa';
import format from 'date-fns/format';
import BreweryPostLikeButton from '@/components/BreweryIndex/BreweryPostLikeButton';
interface BreweryPageProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>;
}
interface BreweryInfoHeaderProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>;
}
const BreweryInfoHeader: FC<BreweryInfoHeaderProps> = ({ breweryPost }) => {
const createdAt = new Date(breweryPost.createdAt);
const timeDistance = useTimeDistance(createdAt);
const { user } = useContext(UserContext);
const idMatches = user && breweryPost.postedBy.id === user.id;
const isPostOwner = !!(user && idMatches);
const { likeCount, mutate } = useGetBreweryPostLikeCount(breweryPost.id);
return (
<article className="card flex flex-col justify-center bg-base-300">
<div className="card-body">
<header className="flex justify-between">
<div className="space-y-2">
<div>
<h1 className="text-2xl font-bold lg:text-4xl">{breweryPost.name}</h1>
<h2 className="text-lg font-semibold lg:text-2xl">
Located in
{` ${breweryPost.city}, ${
breweryPost.stateOrProvince || breweryPost.country
}`}
</h2>
</div>
<div>
<h3 className="italic">
{' posted by '}
<Link
href={`/users/${breweryPost.postedBy.id}`}
className="link-hover link"
>
{`${breweryPost.postedBy.username} `}
</Link>
{timeDistance && (
<span
className="tooltip tooltip-right"
data-tip={format(createdAt, 'MM/dd/yyyy')}
>{`${timeDistance} ago`}</span>
)}
</h3>
</div>
</div>
{isPostOwner && (
<div className="tooltip tooltip-left" data-tip={`Edit '${breweryPost.name}'`}>
<Link
href={`/breweries/${breweryPost.id}/edit`}
className="btn-ghost btn-xs btn"
>
<FaRegEdit className="text-xl" />
</Link>
</div>
)}
</header>
<div className="space-y-2">
<p>{breweryPost.description}</p>
<div className="flex items-end justify-between">
<div className="space-y-1">
<div>
{(!!likeCount || likeCount === 0) && (
<span>
Liked by {likeCount} user{likeCount !== 1 && 's'}
</span>
)}
</div>
</div>
<div className="card-actions">
{user && (
<BreweryPostLikeButton
breweryPostId={breweryPost.id}
mutateCount={mutate}
/>
)}
</div>
</div>
</div>
</div>
</article>
);
};
interface BreweryMapProps {
latitude: number;
longitude: number;
}
const BreweryMap: FC<BreweryMapProps> = ({ latitude, longitude }) => {
return (
<MapGL
initialViewState={{
latitude,
longitude,
zoom: 17,
}}
style={{
width: '100%',
height: 450,
}}
mapStyle="mapbox://styles/mapbox/streets-v12"
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN as string}
scrollZoom={true}
>
<Marker latitude={latitude} longitude={longitude} />
</MapGL>
);
};
const BreweryByIdPage: NextPage<BreweryPageProps> = ({ breweryPost }) => {
const [longitude, latitude] = breweryPost.coordinates;
return (
<>
<h1 className="text-3xl font-bold underline">{breweryPost.name}</h1>
<Head>
<title>{breweryPost.name}</title>
<meta name="description" content={breweryPost.description} />
</Head>
<>
<Carousel
className="w-full"
useKeyboardArrows
autoPlay
interval={10000}
infiniteLoop
showThumbs={false}
>
{breweryPost.breweryImages.length
? breweryPost.breweryImages.map((image, index) => (
<div key={image.id} id={`image-${index}}`} className="w-full">
<Image
alt={image.alt}
src={image.path}
height={1080}
width={1920}
className="h-96 w-full object-cover lg:h-[42rem]"
/>
</div>
))
: Array.from({ length: 1 }).map((_, i) => (
<div className="h-96 lg:h-[42rem]" key={i} />
))}
</Carousel>
<div className="mb-12 mt-10 flex w-full items-center justify-center">
<div className="w-11/12 space-y-3 xl:w-9/12 2xl:w-8/12">
<BreweryInfoHeader breweryPost={breweryPost} />
<BreweryMap latitude={latitude} longitude={longitude} />
</div>
</div>
</>
</>
);
};

View File

@@ -0,0 +1,15 @@
/*
Warnings:
- You are about to drop the column `location` on the `BreweryPost` table. All the data in the column will be lost.
- Added the required column `address` to the `BreweryPost` table without a default value. This is not possible if the table is not empty.
- Added the required column `city` to the `BreweryPost` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "BreweryPost" DROP COLUMN "location";
ALTER TABLE "BreweryPost" ADD COLUMN "address" STRING NOT NULL;
ALTER TABLE "BreweryPost" ADD COLUMN "city" STRING NOT NULL;
ALTER TABLE "BreweryPost" ADD COLUMN "coordinates" FLOAT8[];
ALTER TABLE "BreweryPost" ADD COLUMN "country" STRING;
ALTER TABLE "BreweryPost" ADD COLUMN "stateOrProvince" STRING;

View File

@@ -96,7 +96,11 @@ model BeerType {
model BreweryPost {
id String @id @default(uuid())
name String
location String
city String
stateOrProvince String?
country String?
coordinates Float[]
address String
beers BeerPost[]
description String
createdAt DateTime @default(now()) @db.Timestamptz(3)

View File

@@ -1,4 +1,4 @@
import logger from '@/config/pino/logger';
import logger from '../../../config/pino/logger';
import cleanDatabase from './cleanDatabase';
cleanDatabase().then(() => {

View File

@@ -2,6 +2,7 @@
import { faker } from '@faker-js/faker';
import { User } from '@prisma/client';
import DBClient from '../../DBClient';
import geocode from '../../../config/mapbox/geocoder';
interface CreateNewBreweryPostsArgs {
numberOfPosts: number;
@@ -21,6 +22,15 @@ const createNewBreweryPosts = async ({
for (let i = 0; i < numberOfPosts; i++) {
const name = `${faker.commerce.productName()} Brewing Company`;
const location = faker.address.cityName();
// eslint-disable-next-line no-await-in-loop
const geodata = await geocode(location);
const city = geodata.text;
const stateOrProvince = geodata.context.find((c) => c.id.startsWith('region'))?.text;
const country = geodata.context.find((c) => c.id.startsWith('country'))?.text;
const coordinates = geodata.center;
const address = geodata.place_name;
const description = faker.lorem.lines(5);
const user = users[Math.floor(Math.random() * users.length)];
const createdAt = faker.date.past(1);
@@ -30,7 +40,13 @@ const createNewBreweryPosts = async ({
prisma.breweryPost.create({
data: {
name,
location,
city,
stateOrProvince,
country,
coordinates,
address,
description,
postedBy: { connect: { id: user.id } },
createdAt,

View File

@@ -26,7 +26,7 @@ import createNewBreweryPostLikes from './create/createNewBreweryPostLikes';
const users = await createNewUsers({ numberOfUsers: 1000 });
const [breweryPosts, beerTypes] = await Promise.all([
createNewBreweryPosts({ numberOfPosts: 100, joinData: { users } }),
createNewBreweryPosts({ numberOfPosts: 30, joinData: { users } }),
createNewBeerTypes({ joinData: { users } }),
]);
const beerPosts = await createNewBeerPosts({

View File

@@ -14,7 +14,12 @@ const getAllBreweryPosts = async (pageNum?: number, pageSize?: number) => {
take,
select: {
id: true,
location: true,
coordinates: true,
address: true,
city: true,
stateOrProvince: true,
country: true,
description: true,
name: true,
postedBy: { select: { username: true, id: true } },
breweryImages: { select: { path: true, caption: true, id: true, alt: true } },

View File

@@ -9,7 +9,12 @@ const getBreweryPostById = async (id: string) => {
await prisma.breweryPost.findFirst({
select: {
id: true,
location: true,
coordinates: true,
address: true,
city: true,
stateOrProvince: true,
country: true,
description: true,
name: true,
breweryImages: { select: { path: true, caption: true, id: true, alt: true } },
postedBy: { select: { username: true, id: true } },

View File

@@ -2,8 +2,13 @@ import { z } from 'zod';
const BreweryPostQueryResult = z.object({
id: z.string(),
location: z.string(),
name: z.string(),
description: z.string(),
address: z.string(),
city: z.string(),
stateOrProvince: z.string().or(z.null()),
coordinates: z.array(z.number()),
country: z.string().or(z.null()),
postedBy: z.object({ id: z.string(), username: z.string() }),
breweryImages: z.array(
z.object({ path: z.string(), caption: z.string(), id: z.string(), alt: z.string() }),