mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 10:42:08 +00:00
Feat: Implement mapbox for geocoding and location data for brewery posts
This commit is contained in:
1474
package-lock.json
generated
1474
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@headlessui/tailwindcss": "^0.1.2",
|
||||
"@hookform/resolvers": "^3.0.0",
|
||||
"@mapbox/mapbox-sdk": "^0.15.0",
|
||||
"@prisma/client": "^4.12.0",
|
||||
"@react-email/components": "^0.0.4",
|
||||
"@react-email/render": "^0.0.6",
|
||||
@@ -26,6 +27,7 @@
|
||||
"dotenv": "^16.0.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mapbox-gl": "^2.14.1",
|
||||
"multer": "^2.0.0-rc.4",
|
||||
"multer-storage-cloudinary": "^4.0.0",
|
||||
"next": "^13.2.4",
|
||||
@@ -41,6 +43,7 @@
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-intersection-observer": "^9.4.3",
|
||||
"react-map-gl": "^7.0.23",
|
||||
"react-responsive-carousel": "^3.2.23",
|
||||
"sparkpost": "^2.1.4",
|
||||
"swr": "^2.1.2",
|
||||
@@ -53,6 +56,7 @@
|
||||
"@types/ejs": "^3.1.2",
|
||||
"@types/jsonwebtoken": "^9.0.1",
|
||||
"@types/lodash": "^4.14.192",
|
||||
"@types/mapbox__mapbox-sdk": "^0.13.4",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.15.11",
|
||||
"@types/passport-local": "^1.0.35",
|
||||
|
||||
@@ -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,10 +25,11 @@ 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>
|
||||
<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{' '}
|
||||
@@ -37,16 +40,8 @@ const BeerInfoHeader: FC<{
|
||||
{beerPost.brewery.name}
|
||||
</Link>
|
||||
</h2>
|
||||
</header>
|
||||
{isPostOwner && (
|
||||
<div className="tooltip tooltip-left" data-tip={`Edit '${beerPost.name}'`}>
|
||||
<Link href={`/beers/${beerPost.id}/edit`} className="btn-ghost btn-xs btn">
|
||||
<FaRegEdit className="text-xl" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="italic">
|
||||
{' posted by '}
|
||||
<Link href={`/users/${beerPost.postedBy.id}`} className="link-hover link">
|
||||
@@ -61,7 +56,18 @@ const BeerInfoHeader: FC<{
|
||||
</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">
|
||||
<FaRegEdit className="text-xl" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
<p>{beerPost.description}</p>
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-1">
|
||||
@@ -86,11 +92,14 @@ const BeerInfoHeader: FC<{
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions items-end">
|
||||
{user && <BeerPostLikeButton beerPostId={beerPost.id} mutateCount={mutate} />}
|
||||
{user && (
|
||||
<BeerPostLikeButton beerPostId={beerPost.id} mutateCount={mutate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
12
src/config/env/index.ts
vendored
12
src/config/env/index.ts
vendored
@@ -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;
|
||||
12
src/config/mapbox/geocoder.ts
Normal file
12
src/config/mapbox/geocoder.ts
Normal 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;
|
||||
@@ -37,7 +37,6 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({ beerPost, beerRecommendations }
|
||||
<meta name="description" content={beerPost.description} />
|
||||
</Head>
|
||||
<>
|
||||
<div>
|
||||
<Carousel
|
||||
className="w-full"
|
||||
useKeyboardArrows
|
||||
@@ -63,7 +62,7 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({ beerPost, beerRecommendations }
|
||||
))}
|
||||
</Carousel>
|
||||
|
||||
<div className="mb-12 mt-10 flex w-full items-center justify-center ">
|
||||
<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} />
|
||||
|
||||
@@ -97,8 +96,7 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({ beerPost, beerRecommendations }
|
||||
</Tab.Group>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
15
src/prisma/migrations/20230424192859_/migration.sql
Normal file
15
src/prisma/migrations/20230424192859_/migration.sql
Normal 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;
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import logger from '@/config/pino/logger';
|
||||
import logger from '../../../config/pino/logger';
|
||||
import cleanDatabase from './cleanDatabase';
|
||||
|
||||
cleanDatabase().then(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
Reference in New Issue
Block a user