Merge pull request #41 from aaronpo97/dev-updates

Dev updates
This commit is contained in:
Aaron Po
2023-05-20 21:01:58 -04:00
committed by GitHub
57 changed files with 919 additions and 388 deletions

View File

@@ -5,7 +5,7 @@
The Biergarten App is a web application designed for beer lovers to share their favorite
brews and breweries with like-minded people online.
This application's stack consists of Next.js, Prisma and Vercel Postgres. I'm motivated to
This application's stack consists of Next.js, Prisma and Neon Postgres. I'm motivated to
learn more about these technologies while exploring my passion for beer.
I've also incorporated different APIs into the application, such as the Cloudinary API for
@@ -66,8 +66,8 @@ beer known as iso-alpha acids.
- [Prisma](https://www.prisma.io/)
- An open-source ORM for Node.js and TypeScript applications.
- [Vercel Postgres](https://vercel.com/dashboard/stores)
- A managed PostgreSQL database service provided by Vercel.
- [Neon Postgres](https://neon.tech/)
- A managed PostgreSQL database service powered by Neon.
- [Cloudinary](https://cloudinary.com/)
- A cloud-based image and video management service that provides developers with an easy
way to upload, store, and manipulate media assets.
@@ -94,7 +94,7 @@ You will also need to create a free account with the following services:
- [Cloudinary](https://cloudinary.com/users/register/free)
- [SparkPost](https://www.sparkpost.com/)
- [Vercel Postgres](https://vercel.com/dashboard/stores)
- [Neon Postgres](https://neon.tech/)
- [Mapbox](https://account.mapbox.com/auth/signup/)
### Setup
@@ -126,13 +126,11 @@ SESSION_SECRET=
SESSION_TOKEN_NAME=
SESSION_MAX_AGE=
NODE_ENV=
POSTGRES_URL=
POSTGRES_PRISMA_URL=
POSTGRES_URL_NON_POOLING=
POSTGRES_USER=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=
SHADOW_DATABASE_URL=
MAPBOX_ACCESS_TOKEN=
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
SPARKPOST_API_KEY=
@@ -156,10 +154,13 @@ SPARKPOST_SENDER_ADDRESS=" > .env
- You can set this to `biergarten`.
- `SESSION_MAX_AGE` is the maximum age of the session cookie in milliseconds.
- You can set this to `604800000` (1 week).
- `POSTGRES_URL`, `POSTGRES_PRISMA_URL`, `POSTGRES_URL_NON_POOLING`, `POSTGRES_USER`,
`POSTGRES_HOST`, `POSTGRES_PASSWORD`, and `POSTGRES_DATABASE` are the credentials for
your Vercel Postgres database.
- You can create a free account [here](https://vercel.com/dashboard/stores).
- `POSTGRES_PRISMA_URL`is a pooled connection string for your Neon Postgres database.
- `POSTGRES_URL_NON_POOLING` is a non-pooled connection string for your Neon Postgres
database used for migrations.
- `SHADOW_DATABASE_URL` is a connection string for a secondary database used for
migrations to detect schema drift.
- You can create a free account [here](https://neon.tech)
- Consult the [docs](https://neon.tech/docs/guides/prisma) for more information.
- `MAPBOX_ACCESS_TOKEN` and `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` are the access tokens for
your Mapbox account.
- You can create a free account [here](https://account.mapbox.com/auth/signup/).
@@ -171,7 +172,7 @@ SPARKPOST_SENDER_ADDRESS=" > .env
- You can create a free account [here](https://www.sparkpost.com/).
- `SPARKPOST_SENDER_ADDRESS` is the email address that will be used to send emails.
4. Initialize the database and run the migrations.
1. Initialize the database and run the migrations.
```bash
npx prisma generate

56
package-lock.json generated
View File

@@ -39,6 +39,7 @@
"react-dom": "^18.2.0",
"react-email": "^1.9.3",
"react-hook-form": "^7.43.9",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.8.0",
"react-intersection-observer": "^9.4.3",
"react-map-gl": "^7.0.23",
@@ -3541,8 +3542,7 @@
"node_modules/csstype": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==",
"dev": true
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
"node_modules/daisyui": {
"version": "2.51.6",
@@ -5719,6 +5719,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/goober": {
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.13.tgz",
"integrity": "sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
@@ -9353,6 +9361,21 @@
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-hot-toast": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz",
"integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==",
"dependencies": {
"goober": "^2.1.10"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-icons": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.8.0.tgz",
@@ -11233,9 +11256,9 @@
}
},
"node_modules/vm2": {
"version": "3.9.17",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.17.tgz",
"integrity": "sha512-AqwtCnZ/ERcX+AVj9vUsphY56YANXxRuqMb7GsDtAr0m0PcQX3u0Aj3KWiXM0YAHy7i6JEeHrwOnwXbGYgRpAw==",
"version": "3.9.19",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz",
"integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==",
"optional": true,
"dependencies": {
"acorn": "^8.7.0",
@@ -13934,8 +13957,7 @@
"csstype": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==",
"dev": true
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
"daisyui": {
"version": "2.51.6",
@@ -15557,6 +15579,12 @@
"slash": "^3.0.0"
}
},
"goober": {
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.13.tgz",
"integrity": "sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==",
"requires": {}
},
"gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
@@ -17998,6 +18026,14 @@
"integrity": "sha512-AUDN3Pz2NSeoxQ7Hs6OhQhDr6gtF9YRuutGDwPQqhSUAHJSgGl2VeY3qN19MG0SucpjgDiuMJ4iC5T5uB+eaNQ==",
"requires": {}
},
"react-hot-toast": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz",
"integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==",
"requires": {
"goober": "^2.1.10"
}
},
"react-icons": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.8.0.tgz",
@@ -19389,9 +19425,9 @@
}
},
"vm2": {
"version": "3.9.17",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.17.tgz",
"integrity": "sha512-AqwtCnZ/ERcX+AVj9vUsphY56YANXxRuqMb7GsDtAr0m0PcQX3u0Aj3KWiXM0YAHy7i6JEeHrwOnwXbGYgRpAw==",
"version": "3.9.19",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz",
"integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==",
"optional": true,
"requires": {
"acorn": "^8.7.0",

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
@@ -42,6 +42,7 @@
"react-dom": "^18.2.0",
"react-email": "^1.9.3",
"react-hook-form": "^7.43.9",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.8.0",
"react-intersection-observer": "^9.4.3",
"react-map-gl": "^7.0.23",

View File

@@ -0,0 +1,166 @@
import validateEmail from '@/requests/valdiateEmail';
import validateUsername from '@/requests/validateUsername';
import { BaseCreateUserSchema } from '@/services/User/schema/CreateUserValidationSchemas';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import { Switch } from '@headlessui/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router';
import { FC, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo';
import FormLabel from '../ui/forms/FormLabel';
import FormTextInput from '../ui/forms/FormTextInput';
interface AccountInfoProps {
user: z.infer<typeof GetUserSchema>;
}
const AccountInfo: FC<AccountInfoProps> = ({ user }) => {
const router = useRouter();
const EditUserSchema = BaseCreateUserSchema.pick({
username: true,
email: true,
firstName: true,
lastName: true,
}).extend({
email: z
.string()
.email({ message: 'Email must be a valid email address.' })
.refine(
async (email) => {
if (user.email === email) return true;
return validateEmail(email);
},
{ message: 'Email is already taken.' },
),
username: z
.string()
.min(1, { message: 'Username must not be empty.' })
.max(20, { message: 'Username must be less than 20 characters.' })
.refine(
async (username) => {
if (user.username === username) return true;
return validateUsername(username);
},
{ message: 'Username is already taken.' },
),
});
const { register, handleSubmit, formState, reset } = useForm<
z.infer<typeof EditUserSchema>
>({
resolver: zodResolver(EditUserSchema),
defaultValues: {
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
},
});
const [inEditMode, setInEditMode] = useState(false);
const onSubmit = async (data: z.infer<typeof EditUserSchema>) => {
const response = await fetch(`/api/users/${user.id}/edit`, {
body: JSON.stringify(data),
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error('Something went wrong.');
}
await response.json();
router.reload();
};
return (
<div className="mt-8">
<div className="flex flex-col space-y-3">
<form
className="form-control space-y-5"
onSubmit={handleSubmit(onSubmit)}
noValidate
>
<label className="label w-36 cursor-pointer p-0">
<span className="label-text font-bold uppercase">Enable Edit</span>
<Switch
checked={inEditMode}
className="toggle"
onClick={() => {
setInEditMode((editMode) => !editMode);
reset();
}}
id="edit-toggle"
/>
</label>
<div>
<FormInfo>
<FormLabel htmlFor="username">Username</FormLabel>
<FormError>{formState.errors.username?.message}</FormError>
</FormInfo>
<FormTextInput
type="text"
disabled={!inEditMode || formState.isSubmitting}
error={!!formState.errors.username}
id="username"
formValidationSchema={register('username')}
/>
<FormInfo>
<FormLabel htmlFor="email">Email</FormLabel>
<FormError>{formState.errors.email?.message}</FormError>
</FormInfo>
<FormTextInput
type="email"
disabled={!inEditMode || formState.isSubmitting}
error={!!formState.errors.email}
id="email"
formValidationSchema={register('email')}
/>
<div className="flex space-x-3">
<div className="w-1/2">
<FormInfo>
<FormLabel htmlFor="firstName">First Name</FormLabel>
<FormError>{formState.errors.firstName?.message}</FormError>
</FormInfo>
<FormTextInput
type="text"
disabled={!inEditMode || formState.isSubmitting}
error={!!formState.errors.firstName}
id="firstName"
formValidationSchema={register('firstName')}
/>
</div>
<div className="w-1/2">
<FormInfo>
<FormLabel htmlFor="lastName">Last Name</FormLabel>
<FormError>{formState.errors.lastName?.message}</FormError>
</FormInfo>
<FormTextInput
type="text"
disabled={!inEditMode || formState.isSubmitting}
error={!!formState.errors.lastName}
id="lastName"
formValidationSchema={register('lastName')}
/>
</div>
</div>
</div>
{inEditMode && (
<button className="btn-primary btn w-full" type="submit">
Save Changes
</button>
)}
</form>
</div>
</div>
);
};
export default AccountInfo;

View File

@@ -4,6 +4,7 @@ import { FC, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { z } from 'zod';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import CommentContentBody from './CommentContentBody';
import EditCommentBody from './EditCommentBody';

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import { Dispatch, SetStateAction, FC, useContext } from 'react';
import { FaEllipsisH } from 'react-icons/fa';
import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult';

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import { format } from 'date-fns';
import { Dispatch, FC, SetStateAction, useContext } from 'react';

View File

@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { FC, useState, Dispatch, SetStateAction } from 'react';
import { FC, useState, Dispatch, SetStateAction, useContext } from 'react';
import { Rating } from 'react-daisyui';
import { useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod';
@@ -7,6 +7,7 @@ import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPost
import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import ToastContext from '@/contexts/ToastContext';
import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo';
import FormLabel from '../ui/forms/FormLabel';
@@ -42,6 +43,7 @@ const EditCommentBody: FC<EditCommentBodyProps> = ({
resolver: zodResolver(CreateCommentValidationSchema),
});
const { toast } = useContext(ToastContext);
const { errors } = formState;
const [isDeleting, setIsDeleting] = useState(false);
@@ -58,6 +60,7 @@ const EditCommentBody: FC<EditCommentBodyProps> = ({
setInEditMode(true);
await handleEditRequest(comment.id, data);
await mutate();
toast.success('Submitted edits');
setInEditMode(false);
};

View File

@@ -2,7 +2,7 @@ import Link from 'next/link';
import format from 'date-fns/format';
import { FC, useContext } from 'react';
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import { FaRegEdit } from 'react-icons/fa';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import { z } from 'zod';

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
@@ -6,6 +6,7 @@ import { FC, MutableRefObject, useContext, useRef } from 'react';
import { z } from 'zod';
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import { useRouter } from 'next/router';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import BeerCommentForm from './BeerCommentForm';
import LoadingComponent from './LoadingComponent';
@@ -20,29 +21,25 @@ const BeerPostCommentsSection: FC<BeerPostCommentsSectionProps> = ({ beerPost })
const router = useRouter();
const pageNum = parseInt(router.query.comments_page as string, 10) || 1;
const PAGE_SIZE = 4;
const PAGE_SIZE = 15;
const { comments, isLoading, mutate, setSize, size, isLoadingMore, isAtEnd } =
useBeerPostComments({
id: beerPost.id,
pageNum,
pageSize: PAGE_SIZE,
});
useBeerPostComments({ id: beerPost.id, pageNum, pageSize: PAGE_SIZE });
const commentSectionRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
async function handleDeleteRequest(id: string) {
const handleDeleteRequest = async (id: string) => {
const response = await fetch(`/api/beer-comments/${id}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Failed to delete comment.');
}
}
};
async function handleEditRequest(
const handleEditRequest = async (
id: string,
data: { content: string; rating: number },
) {
data: z.infer<typeof CreateCommentValidationSchema>,
) => {
const response = await fetch(`/api/beer-comments/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -52,7 +49,7 @@ const BeerPostCommentsSection: FC<BeerPostCommentsSectionProps> = ({ beerPost })
if (!response.ok) {
throw new Error('Failed to update comment.');
}
}
};
return (
<div className="w-full space-y-3" ref={commentSectionRef}>

View File

@@ -1,40 +1,101 @@
import BeerRecommendationQueryResult from '@/services/BeerPost/schema/BeerRecommendationQueryResult';
import Link from 'next/link';
import { FunctionComponent } from 'react';
import { FC, MutableRefObject, useRef } from 'react';
import { useInView } from 'react-intersection-observer';
import { z } from 'zod';
import useBeerRecommendations from '@/hooks/data-fetching/beer-posts/useBeerRecommendations';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import debounce from 'lodash/debounce';
import BeerRecommendationLoadingComponent from './BeerRecommendationLoadingComponent';
const BeerRecommendationsSection: FC<{
beerPost: z.infer<typeof beerPostQueryResult>;
}> = ({ beerPost }) => {
const PAGE_SIZE = 10;
const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = useBeerRecommendations({
beerPost,
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;
debounce(() => setSize(size + 1), 200)();
},
});
const beerRecommendationsRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
interface BeerRecommendationsProps {
beerRecommendations: BeerRecommendationQueryResult[];
}
const BeerRecommendations: FunctionComponent<BeerRecommendationsProps> = ({
beerRecommendations,
}) => {
return (
<div className="card sticky top-2 h-full overflow-y-scroll">
<div className="card-body space-y-3">
{beerRecommendations.map((beerPost) => (
<div key={beerPost.id} className="w-full">
<div className="card h-full" ref={beerRecommendationsRef}>
<div className="card-body">
<>
<div className="my-2 flex flex-row items-center justify-between">
<div>
<Link className="link-hover" href={`/beers/${beerPost.id}`} scroll={false}>
<h2 className="truncate text-lg font-bold lg:text-2xl">
{beerPost.name}
</h2>
</Link>
<Link href={`/breweries/${beerPost.brewery.id}`} className="link-hover">
<p className="text-md truncate font-semibold lg:text-xl">
{beerPost.brewery.name}
</p>
</Link>
</div>
<div className="space-x-3 text-sm lg:text-lg">
<span>{beerPost.abv}% ABV</span>
<span>{beerPost.ibu} IBU</span>
<h3 className="text-3xl font-bold">Also check out</h3>
</div>
</div>
))}
{!!beerPosts.length && (
<div className="space-y-5">
{beerPosts.map((post, 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 (
<div
ref={isPenultimateBeerPost ? penultimateBeerPostRef : undefined}
key={post.id}
>
<div className="flex flex-col">
<Link className="link-hover link" href={`/beers/${post.id}`}>
<span className="text-xl font-semibold">{post.name}</span>
</Link>
<Link
className="link-hover link"
href={`/breweries/${post.brewery.id}`}
>
<span className="text-lg font-semibold">{post.brewery.name}</span>
</Link>
</div>
<div>
<div>
<span className="text-lg font-medium">{post.type.name}</span>
</div>
<div className="space-x-2">
<span>{post.abv}% ABV</span>
<span>{post.ibu} IBU</span>
</div>
</div>
</div>
);
})}
</div>
)}
{
/**
* If there are more beer posts to load, show a loading component with a
* skeleton loader and a loading spinner.
*/
!!isLoadingMore && !isAtEnd && (
<BeerRecommendationLoadingComponent length={PAGE_SIZE} />
)
}
</>
</div>
</div>
);
};
export default BeerRecommendations;
export default BeerRecommendationsSection;

View File

@@ -3,7 +3,7 @@ import { FC, useContext } from 'react';
import Image from 'next/image';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import { z } from 'zod';
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount';
import BeerPostLikeButton from '../BeerById/BeerPostLikeButton';

View File

@@ -1,10 +1,11 @@
import UseBeerPostsByBrewery from '@/hooks/data-fetching/beer-posts/useBeerPostsByBrewery';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import Link from 'next/link';
import { FC } from 'react';
import { FC, MutableRefObject, useContext, useRef } from 'react';
import { useInView } from 'react-intersection-observer';
import { z } from 'zod';
import { FaPlus } from 'react-icons/fa';
import UserContext from '@/contexts/UserContext';
import BeerRecommendationLoadingComponent from '../BeerById/BeerRecommendationLoadingComponent';
interface BreweryCommentsSectionProps {
@@ -13,6 +14,8 @@ interface BreweryCommentsSectionProps {
const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) => {
const PAGE_SIZE = 2;
const { user } = useContext(UserContext);
const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = UseBeerPostsByBrewery({
breweryId: breweryPost.id,
pageSize: PAGE_SIZE,
@@ -28,8 +31,10 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
},
});
const beerRecommendationsRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
return (
<div className="card">
<div className="card h-full" ref={beerRecommendationsRef}>
<div className="card-body">
<>
<div className="my-2 flex flex-row items-center justify-between">
@@ -37,13 +42,15 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
<h3 className="text-3xl font-bold">Brews</h3>
</div>
<div>
<Link
className={`btn-ghost btn-sm btn gap-2 rounded-2xl outline`}
href={`/breweries/${breweryPost.id}/beers/create`}
>
<FaPlus className="text-xl" />
Add Beer
</Link>
{user && (
<Link
className={`btn-ghost btn-sm btn gap-2 rounded-2xl outline`}
href={`/breweries/${breweryPost.id}/beers/create`}
>
<FaPlus className="text-xl" />
Add Beer
</Link>
)}
</div>
</div>

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import { FC, MutableRefObject, useContext, useRef } from 'react';
import { z } from 'zod';
@@ -9,6 +9,7 @@ import APIResponseValidationSchema from '@/validation/APIResponseValidationSchem
import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult';
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import ToastContext from '@/contexts/ToastContext';
import LoadingComponent from '../BeerById/LoadingComponent';
import CommentsComponent from '../ui/CommentsComponent';
import CommentForm from '../ui/CommentForm';
@@ -63,6 +64,7 @@ const BreweryCommentForm: FC<BreweryCommentFormProps> = ({ breweryPost, mutate }
resolver: zodResolver(CreateCommentValidationSchema),
});
const { toast } = useContext(ToastContext);
const onSubmit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
data,
) => {
@@ -72,6 +74,7 @@ const BreweryCommentForm: FC<BreweryCommentFormProps> = ({ breweryPost, mutate }
breweryPostId: breweryPost.id,
});
await mutate();
toast.loading('Created new comment.');
reset();
};

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount';
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import { FC, useContext } from 'react';

View File

@@ -5,7 +5,8 @@ import { useRouter } from 'next/router';
import { useContext, useState } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod';
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import ToastContext from '@/contexts/ToastContext';
import ErrorAlert from '../ui/alerts/ErrorAlert';
import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo';
@@ -30,12 +31,15 @@ const LoginForm = () => {
const [responseError, setResponseError] = useState<string>('');
const { mutate } = useContext(UserContext);
const { toast } = useContext(ToastContext);
const onSubmit: SubmitHandler<LoginT> = async (data) => {
try {
const id = toast.loading('Logging in.');
await sendLoginUserRequest(data);
await mutate!();
await router.push(`/user/current`);
toast.remove(id);
} catch (error) {
if (error instanceof Error) {
setResponseError(error.message);

View File

@@ -1,7 +1,5 @@
import sendRegisterUserRequest from '@/requests/sendRegisterUserRequest';
import CreateUserValidationSchema, {
CreateUserValidationSchemaWithUsernameAndEmailCheck,
} from '@/services/User/schema/CreateUserValidationSchema';
import { CreateUserValidationSchemaWithUsernameAndEmailCheck } from '@/services/User/schema/CreateUserValidationSchemas';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router';
import { FC, useState } from 'react';
@@ -19,13 +17,15 @@ import FormTextInput from './ui/forms/FormTextInput';
const RegisterUserForm: FC = () => {
const router = useRouter();
const { reset, register, handleSubmit, formState } = useForm<
z.infer<typeof CreateUserValidationSchema>
z.infer<typeof CreateUserValidationSchemaWithUsernameAndEmailCheck>
>({ resolver: zodResolver(CreateUserValidationSchemaWithUsernameAndEmailCheck) });
const { errors } = formState;
const [serverResponseError, setServerResponseError] = useState('');
const onSubmit = async (data: z.infer<typeof CreateUserValidationSchema>) => {
const onSubmit = async (
data: z.infer<typeof CreateUserValidationSchemaWithUsernameAndEmailCheck>,
) => {
try {
await sendRegisterUserRequest(data);
reset();

View File

@@ -49,9 +49,10 @@ const CommentsComponent: FC<CommentsComponentProps> = ({
handleEditRequest,
}) => {
const { ref: penultimateCommentRef } = useInView({
threshold: 0.1,
/**
* When the second last comment comes into view, call setSize from useBeerPostComments
* to load more comments.
* When the last comment comes into view, call setSize from useBeerPostComments to
* load more comments.
*/
onChange: (visible) => {
if (!visible || isAtEnd) return;
@@ -62,9 +63,9 @@ const CommentsComponent: FC<CommentsComponentProps> = ({
return (
<>
{!!comments.length && (
<div className="card bg-base-300 pb-6">
<div className="card h-full bg-base-300 pb-6">
{comments.map((comment, index) => {
const isPenultimateComment = index === comments.length - 2;
const isLastComment = index === comments.length - 1;
/**
* Attach a ref to the last comment in the list. When it comes into view, the
@@ -72,7 +73,7 @@ const CommentsComponent: FC<CommentsComponentProps> = ({
*/
return (
<div
ref={isPenultimateComment ? penultimateCommentRef : undefined}
ref={isLastComment ? penultimateCommentRef : undefined}
key={comment.id}
>
<CommentCardBody

View File

@@ -0,0 +1,50 @@
import ToastContext from '@/contexts/ToastContext';
import { FC, ReactNode } from 'react';
import toast, { Toast, Toaster, resolveValue } from 'react-hot-toast';
import { FaTimes } from 'react-icons/fa';
const toastToClassName = (toastType: Toast['type']) => {
let className: 'alert-success' | 'alert-error' | 'alert-info';
switch (toastType) {
case 'success':
className = 'alert-success';
break;
case 'error':
className = 'alert-error';
break;
default:
className = 'alert-info';
}
return className;
};
const CustomToast: FC<{ children: ReactNode }> = ({ children }) => {
return (
<ToastContext.Provider value={{ toast }}>
<Toaster>
{(t) => {
const alertType = toastToClassName(t.type);
return (
<div className="flex w-full items-center justify-center">
<div
className={`alert ${alertType} w-11/12 flex-row items-center py-[0.5rem] shadow-lg animate-in fade-in duration-200 lg:w-6/12`}
>
<div>{resolveValue(t.message, t)}</div>
<button
className="btn-ghost btn-circle btn"
onClick={() => toast.dismiss(t.id)}
>
<FaTimes />
</button>
</div>
</div>
);
}}
</Toaster>
{children}
</ToastContext.Provider>
);
};
export default CustomToast;

View File

@@ -19,13 +19,8 @@ const envSchema = z.object({
SESSION_TOKEN_NAME: z.string(),
SESSION_MAX_AGE: z.coerce.number().positive(),
POSTGRES_URL: z.string().url(),
POSTGRES_PRISMA_URL: z.string().url(),
POSTGRES_URL_NON_POOLING: z.string().url(),
POSTGRES_USER: z.string(),
POSTGRES_PASSWORD: z.string(),
POSTGRES_DATABASE: z.string(),
POSTGRES_HOST: z.string(),
SHADOW_DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']),
@@ -119,80 +114,32 @@ export const SESSION_TOKEN_NAME = parsed.data.SESSION_TOKEN_NAME;
export const SESSION_MAX_AGE = parsed.data.SESSION_MAX_AGE;
/**
* PostgreSQL connection URL taken from Vercel.
* PostgreSQL connection URL for Prisma taken from Neon.
*
* @example
* 'postgresql://user:password@host:5432/database';
*
* @see https://vercel.com/dashboard/stores
*/
export const POSTGRES_URL = parsed.data.POSTGRES_URL;
/**
* PostgreSQL connection URL for Prisma taken from Vercel.
*
* @example
* 'postgresql://user:password@host:5432/database';
*
* @see https://vercel.com/dashboard/stores
* @see https://neon.tech/docs/guides/prisma
*/
export const POSTGRES_PRISMA_URL = parsed.data.POSTGRES_PRISMA_URL;
/**
* Non-pooling PostgreSQL connection URL taken from Vercel.
* Non-pooling PostgreSQL connection URL taken from Neon.
*
* @example
* 'postgresql://user:password@host:5432/database';
*
* @see https://vercel.com/dashboard/stores
* @see https://neon.tech/docs/guides/prisma
*/
export const POSTGRES_URL_NON_POOLING = parsed.data.POSTGRES_URL_NON_POOLING;
/**
* The PostgreSQL user from Vercel.
*
* @example
* 'user';
*
* @see https://vercel.com/dashboard/stores
*/
export const POSTGRES_USER = parsed.data.POSTGRES_USER;
/**
* The PostgreSQL password from Vercel.
*
* @example
* 'password';
*
* @see https://vercel.com/dashboard/stores
*/
export const POSTGRES_PASSWORD = parsed.data.POSTGRES_PASSWORD;
/**
* The PostgreSQL database from Vercel.
*
* @example
* 'database';
*
* @see https://vercel.com/dashboard/stores
*/
export const POSTGRES_DATABASE = parsed.data.POSTGRES_DATABASE;
/**
* The PostgreSQL host from Vercel.
*
* @example
* 'ep-sweet-pineapple.us-east-1.postgres.vercel-storage.com';
*
* @see https://vercel.com/dashboard/stores
*/
export const POSTGRES_HOST = parsed.data.POSTGRES_HOST;
/**
* The URL of another PostgreSQL database to shadow.
* The URL of another Neon PostgreSQL database to shadow for migrations.
*
* @example
* 'postgresql://user:password@host:5432/database';
*
* @see https://neon.tech/docs/guides/prisma-migrate
*/
export const SHADOW_DATABASE_URL = parsed.data.SHADOW_DATABASE_URL;

View File

@@ -0,0 +1,8 @@
import { createContext } from 'react';
import toast from 'react-hot-toast';
const ToastContext = createContext<{
toast: typeof toast;
}>({ toast });
export default ToastContext;

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import { useRouter } from 'next/router';
import { useContext } from 'react';

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { useContext } from 'react';
import useSWR from 'swr';

View File

@@ -49,6 +49,7 @@ const useBeerPosts = ({ pageSize }: { pageSize: number }) => {
const { data, error, isLoading, setSize, size } = useSWRInfinite(
(index) => `/api/beers?page_num=${index + 1}&page_size=${pageSize}`,
fetcher,
{ parallel: true },
);
const beerPosts = data?.flatMap((d) => d.beerPosts) ?? [];

View File

@@ -0,0 +1,80 @@
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import useSWRInfinite from 'swr/infinite';
import { z } from 'zod';
interface UseBeerRecommendationsParams {
pageSize: number;
beerPost: z.infer<typeof beerPostQueryResult>;
}
/**
* A custom hook using SWR to fetch beer recommendations from the API.
*
* @param options The options to use when fetching beer recommendations.
* @param options.pageSize The number of beer recommendations to fetch per page.
* @param options.beerPost The beer post to fetch recommendations for.
* @returns An object with the following properties:
*
* - `beerPosts`: The beer posts fetched from the API.
* - `error`: The error that occurred while fetching the data.
* - `isAtEnd`: A boolean indicating whether all data has been fetched.
* - `isLoading`: A boolean indicating whether the data is being fetched.
* - `isLoadingMore`: A boolean indicating whether more data is being fetched.
* - `pageCount`: The total number of pages of data.
* - `setSize`: A function to set the size of the data.
* - `size`: The size of the data.
*/
const UseBeerPostsByBrewery = ({ pageSize, beerPost }: UseBeerRecommendationsParams) => {
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/beers/${beerPost.id}/recommendations/?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;

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { useContext } from 'react';
import useSWR from 'swr';

View File

@@ -46,6 +46,7 @@ const useBreweryPosts = ({ pageSize }: { pageSize: number }) => {
const { data, error, isLoading, setSize, size } = useSWRInfinite(
(index) => `/api/breweries?page_num=${index + 1}&page_size=${pageSize}`,
fetcher,
{ parallel: true },
);
const breweryPosts = data?.flatMap((d) => d.breweryPosts) ?? [];

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import { useRouter } from 'next/router';
import { useState, useEffect, useContext } from 'react';
@@ -34,8 +34,8 @@ const useNavbar = () => {
/** These pages are accessible to both authenticated and unauthenticated users. */
const otherPages: readonly Page[] = [
{ slug: '/breweries', name: 'Breweries' },
{ slug: '/beers', name: 'Beers' },
{ slug: '/breweries', name: 'Breweries' },
];
/**

View File

@@ -1,4 +1,4 @@
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import '@/styles/globals.css';
import type { AppProps } from 'next/app';
@@ -11,6 +11,7 @@ import { Space_Grotesk } from 'next/font/google';
import Head from 'next/head';
import Layout from '@/components/ui/Layout';
import useUser from '@/hooks/auth/useUser';
import CustomToast from '@/components/ui/CustomToast';
const spaceGrotesk = Space_Grotesk({
subsets: ['latin'],
@@ -39,9 +40,12 @@ export default function App({ Component, pageProps }: AppProps) {
</Head>
<UserContext.Provider value={{ user, isLoading, error, mutate }}>
<Layout>
<Component {...pageProps} />
<CustomToast>
<Component {...pageProps} />
</CustomToast>
</Layout>
</UserContext.Provider>
<Analytics />
</>
);

View File

@@ -1,120 +1,17 @@
import withPageAuthRequired from '@/util/withPageAuthRequired';
import { NextPage } from 'next';
import { FC, useState } from 'react';
import { Switch, Tab } from '@headlessui/react';
import { Tab } from '@headlessui/react';
import Head from 'next/head';
import FormInfo from '@/components/ui/forms/FormInfo';
import FormLabel from '@/components/ui/forms/FormLabel';
import FormError from '@/components/ui/forms/FormError';
import FormTextInput from '@/components/ui/forms/FormTextInput';
import { zodResolver } from '@hookform/resolvers/zod';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import DBClient from '@/prisma/DBClient';
import AccountInfo from '@/components/Account/AccountInfo';
interface AccountPageProps {
user: z.infer<typeof GetUserSchema>;
}
const AccountInfo: FC<{
user: z.infer<typeof GetUserSchema>;
}> = ({ user }) => {
const { register, handleSubmit, formState, reset } = useForm<
z.infer<typeof GetUserSchema>
>({
resolver: zodResolver(GetUserSchema),
defaultValues: {
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
dateOfBirth: user.dateOfBirth,
},
});
const [inEditMode, setInEditMode] = useState(false);
return (
<div className="mt-8">
<div className="flex flex-col space-y-3">
<div className="flex flex-row">
<label className="label-text" htmlFor="edit-toggle">
Edit Account Info
</label>
<Switch
checked={inEditMode}
className="toggle"
onClick={() => {
setInEditMode((editMode) => !editMode);
reset();
}}
id="edit-toggle"
/>
</div>
<form className="space-y-5" onSubmit={handleSubmit(() => {})}>
<div>
<FormInfo>
<FormLabel htmlFor="username">Username</FormLabel>
<FormError>{formState.errors.username?.message}</FormError>
</FormInfo>
<FormTextInput
type="text"
disabled={!inEditMode || formState.isSubmitting}
error={!!formState.errors.username}
id="username"
formValidationSchema={register('username')}
/>
<FormInfo>
<FormLabel htmlFor="email">Email</FormLabel>
<FormError>{''}</FormError>
</FormInfo>
<FormTextInput
type="email"
disabled={!inEditMode || formState.isSubmitting}
error={!!formState.errors.email}
id="email"
formValidationSchema={register('email')}
/>
<div className="flex space-x-3">
<div className="w-1/2">
<FormInfo>
<FormLabel htmlFor="firstName">First Name</FormLabel>
<FormError>{formState.errors.firstName?.message}</FormError>
</FormInfo>
<FormTextInput
type="text"
disabled={!inEditMode || formState.isSubmitting}
error={!!formState.errors.firstName}
id="firstName"
formValidationSchema={register('firstName')}
/>
</div>
<div className="w-1/2">
<FormInfo>
<FormLabel htmlFor="lastName">Last Name</FormLabel>
<FormError>{formState.errors.lastName?.message}</FormError>
</FormInfo>
<FormTextInput
type="text"
disabled={!inEditMode || formState.isSubmitting}
error={!!formState.errors.lastName}
id="lastName"
formValidationSchema={register('lastName')}
/>
</div>
</div>
</div>
{inEditMode && <button className="btn-primary btn w-full">Save Changes</button>}
</form>
</div>
</div>
);
};
const AccountPage: NextPage<AccountPageProps> = ({ user }) => {
return (
<>
@@ -126,7 +23,7 @@ const AccountPage: NextPage<AccountPageProps> = ({ user }) => {
/>
</Head>
<div className="flex h-full flex-col items-center bg-base-300">
<div className="m-12 flex w-9/12 flex-col items-center justify-center space-y-3">
<div className="m-12 flex w-11/12 flex-col items-center justify-center space-y-3 lg:w-7/12">
<div className="flex flex-col items-center space-y-3">
<div className="avatar">
<div className="bg-base-black w-24 rounded-full bg-slate-700" />
@@ -141,10 +38,13 @@ const AccountPage: NextPage<AccountPageProps> = ({ user }) => {
<div className="w-full">
<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">
<Tab className="tab tab-md w-1/3 uppercase ui-selected:tab-active">
Account Info
</Tab>
<Tab className="tab tab-md w-1/2 uppercase ui-selected:tab-active">
<Tab className="tab tab-md w-1/3 uppercase ui-selected:tab-active">
Security
</Tab>
<Tab className="tab tab-md w-1/3 uppercase ui-selected:tab-active">
Your Posts
</Tab>
</Tab.List>

View File

@@ -4,8 +4,9 @@ import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import ServerError from '@/config/util/ServerError';
import DBClient from '@/prisma/DBClient';
import findBeerCommentById from '@/services/BeerComment/findBeerCommentById';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import editBeerCommentById from '@/services/BeerComment/editBeerCommentById';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { createRouter, NextHandler } from 'next-connect';
@@ -27,9 +28,7 @@ const checkIfCommentOwner = async (
) => {
const { id } = req.query;
const user = req.user!;
const comment = await DBClient.instance.beerComment.findUnique({
where: { id },
});
const comment = await findBeerCommentById(id);
if (!comment) {
throw new ServerError('Comment not found', 404);
@@ -48,13 +47,10 @@ const editComment = async (
) => {
const { id } = req.query;
const updated = await DBClient.instance.beerComment.update({
where: { id },
data: {
content: req.body.content,
rating: req.body.rating,
updatedAt: new Date(),
},
const updated = await editBeerCommentById({
content: req.body.content,
rating: req.body.rating,
id,
});
return res.status(200).json({

View File

@@ -1,5 +1,3 @@
import DBClient from '@/prisma/DBClient';
import { BeerImage } from '@prisma/client';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
@@ -14,6 +12,8 @@ import { NextApiResponse } from 'next';
import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import processImageDataIntoDB from '@/services/BeerImage/processImageDataIntoDB';
import ImageMetadataValidationSchema from '@/services/types/ImageSchema/ImageMetadataValidationSchema';
const { storage } = cloudinaryConfig;
@@ -34,15 +34,10 @@ const uploadMiddleware = expressWrapper(
),
);
const BeerPostImageValidationSchema = z.object({
caption: z.string(),
alt: z.string(),
});
interface UploadBeerPostImagesRequest extends UserExtendedNextApiRequest {
files?: Express.Multer.File[];
query: { id: string };
body: z.infer<typeof BeerPostImageValidationSchema>;
body: z.infer<typeof ImageMetadataValidationSchema>;
}
const processImageData = async (
@@ -54,24 +49,15 @@ const processImageData = async (
if (!files || !files.length) {
throw new ServerError('No images uploaded', 400);
}
const beerImagePromises: Promise<BeerImage>[] = [];
files.forEach((file) => {
beerImagePromises.push(
DBClient.instance.beerImage.create({
data: {
alt: body.alt,
postedBy: { connect: { id: user!.id } },
beerPost: { connect: { id: req.query.id } },
path: file.path,
caption: body.caption,
},
}),
);
const beerImages = await processImageDataIntoDB({
alt: body.alt,
caption: body.caption,
beerPostId: req.query.id,
userId: user!.id,
files,
});
const beerImages = await Promise.all(beerImagePromises);
res.status(200).json({
success: true,
message: `Successfully uploaded ${beerImages.length} image${
@@ -90,7 +76,7 @@ router.post(
getCurrentUser,
// @ts-expect-error
uploadMiddleware,
validateRequest({ bodySchema: BeerPostImageValidationSchema }),
validateRequest({ bodySchema: ImageMetadataValidationSchema }),
processImageData,
);

View File

@@ -11,7 +11,7 @@ import removeBeerPostLikeById from '@/services/BeerPostLike/removeBeerPostLikeBy
import findBeerPostLikeById from '@/services/BeerPostLike/findBeerPostLikeById';
import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import DBClient from '@/prisma/DBClient';
import getBeerPostLikeCount from '@/services/BeerPostLike/getBeerPostLikeCount';
const sendLikeRequest = async (
req: UserExtendedNextApiRequest,
@@ -25,7 +25,10 @@ const sendLikeRequest = async (
throw new ServerError('Could not find a beer post with that id', 404);
}
const alreadyLiked = await findBeerPostLikeById(beer.id, user.id);
const alreadyLiked = await findBeerPostLikeById({
beerPostId: beer.id,
likedById: user.id,
});
const jsonResponse = {
success: true as const,
@@ -50,9 +53,7 @@ const getLikeCount = async (
) => {
const id = req.query.id as string;
const likeCount = await DBClient.instance.beerPostLike.count({
where: { beerPostId: id },
});
const likeCount = await getBeerPostLikeCount(id);
res.status(200).json({
success: true,

View File

@@ -2,25 +2,20 @@ import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import DBClient from '@/prisma/DBClient';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
import findBeerPostLikeById from '@/services/BeerPostLike/findBeerPostLikeById';
const checkIfLiked = async (
req: UserExtendedNextApiRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const user = req.user!;
const id = req.query.id as string;
const beerPostId = req.query.id as string;
const alreadyLiked = await DBClient.instance.beerPostLike.findFirst({
where: {
beerPostId: id,
likedById: user.id,
},
});
const alreadyLiked = await findBeerPostLikeById({ beerPostId, likedById: user.id });
res.status(200).json({
success: true,
@@ -37,11 +32,7 @@ const router = createRouter<
router.get(
getCurrentUser,
validateRequest({
querySchema: z.object({
id: z.string().uuid(),
}),
}),
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }),
checkIfLiked,
);

View File

@@ -0,0 +1,63 @@
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import ServerError from '@/config/util/ServerError';
import getBeerPostById from '@/services/BeerPost/getBeerPostById';
import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiRequest, NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
interface BeerPostRequest extends NextApiRequest {
query: { id: string; page_num: string; page_size: string };
}
const router = createRouter<
BeerPostRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
const getBeerRecommendationsRequest = async (
req: BeerPostRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { id } = req.query;
const beerPost = await getBeerPostById(id);
if (!beerPost) {
throw new ServerError('Beer post not found', 404);
}
const pageNum = parseInt(req.query.page_num as string, 10);
const pageSize = parseInt(req.query.page_size as string, 10);
const { count, beerRecommendations } = await getBeerRecommendations({
beerPost,
pageNum,
pageSize,
});
res.setHeader('X-Total-Count', count);
res.status(200).json({
success: true,
message: 'Recommendations fetched successfully',
statusCode: 200,
payload: beerRecommendations,
});
};
router.get(
validateRequest({
querySchema: z.object({
id: z.string().uuid(),
page_num: z.string().regex(/^[0-9]+$/),
page_size: z.string().regex(/^[0-9]+$/),
}),
}),
getBeerRecommendationsRequest,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -28,7 +28,12 @@ const getBreweryPosts = async (
await DBClient.instance.breweryPost.findMany({
select: {
location: {
select: { coordinates: true, city: true, country: true, stateOrProvince: true },
select: {
coordinates: true,
city: true,
country: true,
stateOrProvince: true,
},
},
id: true,
name: true,

View File

@@ -0,0 +1,108 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import ServerError from '@/config/util/ServerError';
import DBClient from '@/prisma/DBClient';
import findUserByEmail from '@/services/User/findUserByEmail';
import findUserById from '@/services/User/findUserById';
import findUserByUsername from '@/services/User/findUserByUsername';
import { BaseCreateUserSchema } from '@/services/User/schema/CreateUserValidationSchemas';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { NextHandler, createRouter } from 'next-connect';
import { z } from 'zod';
const EditUserSchema = BaseCreateUserSchema.pick({
username: true,
email: true,
firstName: true,
lastName: true,
});
interface EditUserRequest extends UserExtendedNextApiRequest {
body: z.infer<typeof EditUserSchema>;
query: {
id: string;
};
}
const checkIfUserCanEditUser = async (
req: EditUserRequest,
res: NextApiResponse,
next: NextHandler,
) => {
const authenticatedUser = req.user!;
const userToUpdate = await findUserById(req.query.id);
if (!userToUpdate) {
throw new ServerError('User not found', 404);
}
if (authenticatedUser.id !== userToUpdate.id) {
throw new ServerError('You are not permitted to edit this user', 403);
}
await next();
};
const editUser = async (
req: EditUserRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { email, firstName, lastName, username } = req.body;
const [usernameIsTaken, emailIsTaken] = await Promise.all([
findUserByUsername(username),
findUserByEmail(email),
]);
const emailChanged = req.user!.email !== email;
const usernameChanged = req.user!.username !== username;
if (emailIsTaken && emailChanged) {
throw new ServerError('Email is already taken', 400);
}
if (usernameIsTaken && usernameChanged) {
throw new ServerError('Username is already taken', 400);
}
const updatedUser = await DBClient.instance.user.update({
where: { id: req.user!.id },
data: {
email,
firstName,
lastName,
username,
accountIsVerified: emailChanged ? false : undefined,
},
});
res.json({
message: 'User edited successfully',
payload: updatedUser,
success: true,
statusCode: 200,
});
};
const router = createRouter<
EditUserRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.put(
getCurrentUser,
validateRequest({
bodySchema: EditUserSchema,
querySchema: z.object({ id: z.string().uuid() }),
}),
checkIfUserCanEditUser,
editUser,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -29,7 +29,7 @@ const checkEmail = async (req: NextApiRequest, res: NextApiResponse) => {
success: true,
payload: { emailIsTaken: !!email },
statusCode: 200,
message: 'Getting username availability.',
message: 'Getting email availability.',
});
};

View File

@@ -4,7 +4,7 @@ import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
import { createRouter } from 'next-connect';
import createNewUser from '@/services/User/createNewUser';
import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema';
import { CreateUserValidationSchema } from '@/services/User/schema/CreateUserValidationSchemas';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import findUserByUsername from '@/services/User/findUserByUsername';
import findUserByEmail from '@/services/User/findUserByEmail';

View File

@@ -2,15 +2,9 @@ import { NextPage, GetServerSideProps } from 'next';
import Head from 'next/head';
import Image from 'next/image';
import BeerInfoHeader from '@/components/BeerById/BeerInfoHeader';
import BeerPostCommentsSection from '@/components/BeerById/BeerPostCommentsSection';
import BeerRecommendations from '@/components/BeerById/BeerRecommendations';
import getBeerPostById from '@/services/BeerPost/getBeerPostById';
import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import { BeerPost } from '@prisma/client';
import { z } from 'zod';
@@ -18,16 +12,19 @@ import 'react-responsive-carousel/lib/styles/carousel.min.css';
import { Carousel } from 'react-responsive-carousel';
import useMediaQuery from '@/hooks/utilities/useMediaQuery';
import { Tab } from '@headlessui/react';
import dynamic from 'next/dynamic';
const [BeerInfoHeader, BeerPostCommentsSection, BeerRecommendations] = [
dynamic(() => import('@/components/BeerById/BeerInfoHeader')),
dynamic(() => import('@/components/BeerById/BeerPostCommentsSection')),
dynamic(() => import('@/components/BeerById/BeerRecommendations')),
];
interface BeerPageProps {
beerPost: z.infer<typeof beerPostQueryResult>;
beerRecommendations: (BeerPost & {
brewery: { id: string; name: string };
beerImages: { id: string; alt: string; url: string }[];
})[];
}
const BeerByIdPage: NextPage<BeerPageProps> = ({ beerPost, beerRecommendations }) => {
const BeerByIdPage: NextPage<BeerPageProps> = ({ beerPost }) => {
const isDesktop = useMediaQuery('(min-width: 1024px)');
return (
@@ -72,7 +69,7 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({ beerPost, beerRecommendations }
<BeerPostCommentsSection beerPost={beerPost} />
</div>
<div className="w-[40%]">
<BeerRecommendations beerRecommendations={beerRecommendations} />
<BeerRecommendations beerPost={beerPost} />
</div>
</div>
) : (
@@ -90,7 +87,7 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({ beerPost, beerRecommendations }
<BeerPostCommentsSection beerPost={beerPost} />
</Tab.Panel>
<Tab.Panel>
<BeerRecommendations beerRecommendations={beerRecommendations} />
<BeerRecommendations beerPost={beerPost} />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
@@ -109,12 +106,8 @@ export const getServerSideProps: GetServerSideProps<BeerPageProps> = async (cont
return { notFound: true };
}
const { type, brewery, id } = beerPost;
const beerRecommendations = await getBeerRecommendations({ type, brewery, id });
const props = {
beerPost: JSON.parse(JSON.stringify(beerPost)),
beerRecommendations: JSON.parse(JSON.stringify(beerRecommendations)),
};
return { props };

View File

@@ -9,7 +9,7 @@ import { FaArrowUp } from 'react-icons/fa';
import LoadingCard from '@/components/ui/LoadingCard';
const BeerPage: NextPage = () => {
const PAGE_SIZE = 6;
const PAGE_SIZE = 20;
const { beerPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } = useBeerPosts({
pageSize: PAGE_SIZE,

View File

@@ -9,10 +9,15 @@ import 'react-responsive-carousel/lib/styles/carousel.min.css'; // requires a lo
import { Carousel } from 'react-responsive-carousel';
import useMediaQuery from '@/hooks/utilities/useMediaQuery';
import { Tab } from '@headlessui/react';
import BreweryInfoHeader from '@/components/BreweryById/BreweryInfoHeader';
import BreweryPostMap from '@/components/BreweryById/BreweryPostMap';
import BreweryBeersSection from '@/components/BreweryById/BreweryBeerSection';
import BreweryCommentsSection from '@/components/BreweryById/BreweryCommentsSection';
import dynamic from 'next/dynamic';
const [BreweryInfoHeader, BreweryBeersSection, BreweryCommentsSection, BreweryPostMap] = [
dynamic(() => import('@/components/BreweryById/BreweryInfoHeader')),
dynamic(() => import('@/components/BreweryById/BreweryBeerSection')),
dynamic(() => import('@/components/BreweryById/BreweryCommentsSection')),
dynamic(() => import('@/components/BreweryById/BreweryPostMap')),
];
interface BreweryPageProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>;

View File

@@ -1,7 +1,7 @@
import BreweryCard from '@/components/BreweryIndex/BreweryCard';
import LoadingCard from '@/components/ui/LoadingCard';
import Spinner from '@/components/ui/Spinner';
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import useBreweryPosts from '@/hooks/data-fetching/brewery-posts/useBreweryPosts';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import { NextPage } from 'next';
@@ -17,7 +17,7 @@ interface BreweryPageProps {
}
const BreweryPage: NextPage<BreweryPageProps> = () => {
const PAGE_SIZE = 6;
const PAGE_SIZE = 20;
const { breweryPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } =
useBreweryPosts({

View File

@@ -1,6 +1,6 @@
import Spinner from '@/components/ui/Spinner';
import withPageAuthRequired from '@/util/withPageAuthRequired';
import UserContext from '@/contexts/userContext';
import UserContext from '@/contexts/UserContext';
import { GetServerSideProps, NextPage } from 'next';
import { useContext } from 'react';

View File

@@ -1,4 +1,4 @@
import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema';
import { CreateUserValidationSchema } from '@/services/User/schema/CreateUserValidationSchemas';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod';
@@ -6,9 +6,7 @@ import { z } from 'zod';
async function sendRegisterUserRequest(data: z.infer<typeof CreateUserValidationSchema>) {
const response = await fetch('/api/users/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});

View File

@@ -12,14 +12,14 @@ const validateEmail = async (email: string) => {
}
const parsedPayload = z
.object({ usernameIsTaken: z.boolean() })
.object({ emailIsTaken: z.boolean() })
.safeParse(parsed.data.payload);
if (!parsedPayload.success) {
return false;
}
return !parsedPayload.data.usernameIsTaken;
return !parsedPayload.data.emailIsTaken;
};
export default validateEmail;

View File

@@ -0,0 +1,22 @@
import DBClient from '@/prisma/DBClient';
interface EditBeerCommentByIdArgs {
id: string;
content: string;
rating: number;
}
const editBeerCommentById = async ({ id, content, rating }: EditBeerCommentByIdArgs) => {
const updated = await DBClient.instance.beerComment.update({
where: { id },
data: {
content,
rating,
updatedAt: new Date(),
},
});
return updated;
};
export default editBeerCommentById;

View File

@@ -0,0 +1,11 @@
import DBClient from '@/prisma/DBClient';
const findBeerCommentById = async (id: string) => {
const comment = await DBClient.instance.beerComment.findUnique({
where: { id },
});
return comment;
};
export default findBeerCommentById;

View File

@@ -0,0 +1,39 @@
import DBClient from '@/prisma/DBClient';
import { BeerImage } from '@prisma/client';
import { z } from 'zod';
import ImageMetadataValidationSchema from '../types/ImageSchema/ImageMetadataValidationSchema';
interface ProcessImageDataArgs {
files: Express.Multer.File[];
alt: z.infer<typeof ImageMetadataValidationSchema>['alt'];
caption: z.infer<typeof ImageMetadataValidationSchema>['caption'];
beerPostId: string;
userId: string;
}
const processImageDataIntoDB = ({
alt,
caption,
files,
beerPostId,
userId,
}: ProcessImageDataArgs) => {
const beerImagePromises: Promise<BeerImage>[] = [];
files.forEach((file) => {
beerImagePromises.push(
DBClient.instance.beerImage.create({
data: {
alt,
caption,
postedBy: { connect: { id: userId } },
beerPost: { connect: { id: beerPostId } },
path: file.path,
},
}),
);
});
return Promise.all(beerImagePromises);
};
export default processImageDataIntoDB;

View File

@@ -1,22 +1,51 @@
import DBClient from '@/prisma/DBClient';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import { z } from 'zod';
const getBeerRecommendations = async (
beerPost: Pick<z.infer<typeof beerPostQueryResult>, 'type' | 'brewery' | 'id'>,
) => {
const beerRecommendations = await DBClient.instance.beerPost.findMany({
interface GetBeerRecommendationsArgs {
beerPost: z.infer<typeof BeerPostQueryResult>;
pageNum: number;
pageSize: number;
}
const getBeerRecommendations = async ({
beerPost,
pageNum,
pageSize,
}: GetBeerRecommendationsArgs) => {
const skip = (pageNum - 1) * pageSize;
const take = pageSize;
const beerRecommendations: z.infer<typeof BeerPostQueryResult>[] =
await DBClient.instance.beerPost.findMany({
where: {
OR: [{ typeId: beerPost.type.id }, { breweryId: beerPost.brewery.id }],
NOT: { id: beerPost.id },
},
select: {
id: true,
name: true,
ibu: true,
abv: true,
description: true,
createdAt: true,
type: { select: { name: true, id: true } },
brewery: { select: { name: true, id: true } },
postedBy: { select: { id: true, username: true } },
beerImages: { select: { path: true, caption: true, id: true, alt: true } },
},
take,
skip,
});
const count = await DBClient.instance.beerPost.count({
where: {
OR: [{ typeId: beerPost.type.id }, { breweryId: beerPost.brewery.id }],
NOT: { id: beerPost.id },
},
include: {
beerImages: { select: { id: true, path: true, caption: true, alt: true } },
brewery: { select: { id: true, name: true } },
},
});
return beerRecommendations;
return { beerRecommendations, count };
};
export default getBeerRecommendations;

View File

@@ -1,6 +1,14 @@
import DBClient from '@/prisma/DBClient';
const findBeerPostLikeById = async (beerPostId: string, likedById: string) =>
interface FindBeerPostLikeByIdArgs {
beerPostId: string;
likedById: string;
}
const findBeerPostLikeById = async ({
beerPostId,
likedById,
}: FindBeerPostLikeByIdArgs) =>
DBClient.instance.beerPostLike.findFirst({ where: { beerPostId, likedById } });
export default findBeerPostLikeById;

View File

@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from 'zod';
const BreweryPostMapQueryResult = z.object({
location: z.object({

View File

@@ -1,7 +1,7 @@
import { hashPassword } from '@/config/auth/passwordFns';
import DBClient from '@/prisma/DBClient';
import { z } from 'zod';
import CreateUserValidationSchema from './schema/CreateUserValidationSchema';
import { CreateUserValidationSchema } from './schema/CreateUserValidationSchemas';
import GetUserSchema from './schema/GetUserSchema';
const createNewUser = async ({

View File

@@ -3,9 +3,9 @@ import validateUsername from '@/requests/validateUsername';
import sub from 'date-fns/sub';
import { z } from 'zod';
const minimumDateOfBirth = sub(new Date(), { years: 19 });
const CreateUserValidationSchema = z.object({
// use special characters, numbers, and uppercase letters
const MINIMUM_DATE_OF_BIRTH = sub(new Date(), { years: 19 });
export const BaseCreateUserSchema = z.object({
password: z
.string()
.min(8, { message: 'Password must be at least 8 characters.' })
@@ -33,29 +33,25 @@ const CreateUserValidationSchema = z.object({
.refine((lastName) => /^[a-zA-Z]+$/.test(lastName), {
message: 'Last name must only contain letters.',
}),
dateOfBirth: z.string().refine(
(dateOfBirth) => {
const parsedDateOfBirth = new Date(dateOfBirth);
return parsedDateOfBirth <= minimumDateOfBirth;
},
{ message: 'You must be at least 19 years old to register.' },
),
});
export default CreateUserValidationSchema.extend({
dateOfBirth: z
.string()
.refine((dateOfBirth) => new Date(dateOfBirth) <= MINIMUM_DATE_OF_BIRTH, {
message: 'You must be at least 19 years old to register.',
}),
username: z
.string()
.min(1, { message: 'Username must not be empty.' })
.max(20, { message: 'Username must be less than 20 characters.' }),
email: z.string().email({ message: 'Email must be a valid email address.' }),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
});
export const CreateUserValidationSchema = BaseCreateUserSchema.refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords do not match.', path: ['confirmPassword'] },
);
export const CreateUserValidationSchemaWithUsernameAndEmailCheck =
CreateUserValidationSchema.extend({
BaseCreateUserSchema.extend({
email: z
.string()
.email({ message: 'Email must be a valid email address.' })

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
const ImageMetadataValidationSchema = z.object({
caption: z.string().min(1, { message: 'Caption is required.' }),
alt: z.string().min(1, { message: 'Alt text is required.' }),
});
export default ImageMetadataValidationSchema;