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 The Biergarten App is a web application designed for beer lovers to share their favorite
brews and breweries with like-minded people online. 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. 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 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/) - [Prisma](https://www.prisma.io/)
- An open-source ORM for Node.js and TypeScript applications. - An open-source ORM for Node.js and TypeScript applications.
- [Vercel Postgres](https://vercel.com/dashboard/stores) - [Neon Postgres](https://neon.tech/)
- A managed PostgreSQL database service provided by Vercel. - A managed PostgreSQL database service powered by Neon.
- [Cloudinary](https://cloudinary.com/) - [Cloudinary](https://cloudinary.com/)
- A cloud-based image and video management service that provides developers with an easy - A cloud-based image and video management service that provides developers with an easy
way to upload, store, and manipulate media assets. 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) - [Cloudinary](https://cloudinary.com/users/register/free)
- [SparkPost](https://www.sparkpost.com/) - [SparkPost](https://www.sparkpost.com/)
- [Vercel Postgres](https://vercel.com/dashboard/stores) - [Neon Postgres](https://neon.tech/)
- [Mapbox](https://account.mapbox.com/auth/signup/) - [Mapbox](https://account.mapbox.com/auth/signup/)
### Setup ### Setup
@@ -126,13 +126,11 @@ SESSION_SECRET=
SESSION_TOKEN_NAME= SESSION_TOKEN_NAME=
SESSION_MAX_AGE= SESSION_MAX_AGE=
NODE_ENV= NODE_ENV=
POSTGRES_URL=
POSTGRES_PRISMA_URL= POSTGRES_PRISMA_URL=
POSTGRES_URL_NON_POOLING= POSTGRES_URL_NON_POOLING=
POSTGRES_USER= SHADOW_DATABASE_URL=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=
MAPBOX_ACCESS_TOKEN= MAPBOX_ACCESS_TOKEN=
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
SPARKPOST_API_KEY= SPARKPOST_API_KEY=
@@ -156,10 +154,13 @@ SPARKPOST_SENDER_ADDRESS=" > .env
- You can set this to `biergarten`. - You can set this to `biergarten`.
- `SESSION_MAX_AGE` is the maximum age of the session cookie in milliseconds. - `SESSION_MAX_AGE` is the maximum age of the session cookie in milliseconds.
- You can set this to `604800000` (1 week). - You can set this to `604800000` (1 week).
- `POSTGRES_URL`, `POSTGRES_PRISMA_URL`, `POSTGRES_URL_NON_POOLING`, `POSTGRES_USER`, - `POSTGRES_PRISMA_URL`is a pooled connection string for your Neon Postgres database.
`POSTGRES_HOST`, `POSTGRES_PASSWORD`, and `POSTGRES_DATABASE` are the credentials for - `POSTGRES_URL_NON_POOLING` is a non-pooled connection string for your Neon Postgres
your Vercel Postgres database. database used for migrations.
- You can create a free account [here](https://vercel.com/dashboard/stores). - `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 - `MAPBOX_ACCESS_TOKEN` and `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` are the access tokens for
your Mapbox account. your Mapbox account.
- You can create a free account [here](https://account.mapbox.com/auth/signup/). - 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/). - 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. - `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 ```bash
npx prisma generate npx prisma generate

56
package-lock.json generated
View File

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

View File

@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@@ -42,6 +42,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-email": "^1.9.3", "react-email": "^1.9.3",
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
"react-intersection-observer": "^9.4.3", "react-intersection-observer": "^9.4.3",
"react-map-gl": "^7.0.23", "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 { useInView } from 'react-intersection-observer';
import { z } from 'zod'; import { z } from 'zod';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema'; import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import CommentContentBody from './CommentContentBody'; import CommentContentBody from './CommentContentBody';
import EditCommentBody from './EditCommentBody'; 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 { Dispatch, SetStateAction, FC, useContext } from 'react';
import { FaEllipsisH } from 'react-icons/fa'; import { FaEllipsisH } from 'react-icons/fa';
import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult'; 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 useTimeDistance from '@/hooks/utilities/useTimeDistance';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Dispatch, FC, SetStateAction, useContext } from 'react'; import { Dispatch, FC, SetStateAction, useContext } from 'react';

View File

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

View File

@@ -2,7 +2,7 @@ import Link from 'next/link';
import format from 'date-fns/format'; import format from 'date-fns/format';
import { FC, useContext } from 'react'; import { FC, useContext } from 'react';
import UserContext from '@/contexts/userContext'; import UserContext from '@/contexts/UserContext';
import { FaRegEdit } from 'react-icons/fa'; import { FaRegEdit } from 'react-icons/fa';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import { z } from 'zod'; 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'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
@@ -6,6 +6,7 @@ import { FC, MutableRefObject, useContext, useRef } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments'; import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import BeerCommentForm from './BeerCommentForm'; import BeerCommentForm from './BeerCommentForm';
import LoadingComponent from './LoadingComponent'; import LoadingComponent from './LoadingComponent';
@@ -20,29 +21,25 @@ const BeerPostCommentsSection: FC<BeerPostCommentsSectionProps> = ({ beerPost })
const router = useRouter(); const router = useRouter();
const pageNum = parseInt(router.query.comments_page as string, 10) || 1; 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 } = const { comments, isLoading, mutate, setSize, size, isLoadingMore, isAtEnd } =
useBeerPostComments({ useBeerPostComments({ id: beerPost.id, pageNum, pageSize: PAGE_SIZE });
id: beerPost.id,
pageNum,
pageSize: PAGE_SIZE,
});
const commentSectionRef: MutableRefObject<HTMLDivElement | null> = useRef(null); 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' }); const response = await fetch(`/api/beer-comments/${id}`, { method: 'DELETE' });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to delete comment.'); throw new Error('Failed to delete comment.');
} }
} };
async function handleEditRequest( const handleEditRequest = async (
id: string, id: string,
data: { content: string; rating: number }, data: z.infer<typeof CreateCommentValidationSchema>,
) { ) => {
const response = await fetch(`/api/beer-comments/${id}`, { const response = await fetch(`/api/beer-comments/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -52,7 +49,7 @@ const BeerPostCommentsSection: FC<BeerPostCommentsSectionProps> = ({ beerPost })
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to update comment.'); throw new Error('Failed to update comment.');
} }
} };
return ( return (
<div className="w-full space-y-3" ref={commentSectionRef}> <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 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 ( return (
<div className="card sticky top-2 h-full overflow-y-scroll"> <div className="card h-full" ref={beerRecommendationsRef}>
<div className="card-body space-y-3"> <div className="card-body">
{beerRecommendations.map((beerPost) => ( <>
<div key={beerPost.id} className="w-full"> <div className="my-2 flex flex-row items-center justify-between">
<div> <div>
<Link className="link-hover" href={`/beers/${beerPost.id}`} scroll={false}> <h3 className="text-3xl font-bold">Also check out</h3>
<h2 className="truncate text-lg font-bold lg:text-2xl"> </div>
{beerPost.name} </div>
</h2>
{!!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>
<Link href={`/breweries/${beerPost.brewery.id}`} className="link-hover">
<p className="text-md truncate font-semibold lg:text-xl"> <Link
{beerPost.brewery.name} className="link-hover link"
</p> href={`/breweries/${post.brewery.id}`}
>
<span className="text-lg font-semibold">{post.brewery.name}</span>
</Link> </Link>
</div> </div>
<div className="space-x-3 text-sm lg:text-lg"> <div>
<span>{beerPost.abv}% ABV</span> <div>
<span>{beerPost.ibu} IBU</span> <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>
))} </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>
</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 Image from 'next/image';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import { z } from 'zod'; import { z } from 'zod';
import UserContext from '@/contexts/userContext'; import UserContext from '@/contexts/UserContext';
import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount'; import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount';
import BeerPostLikeButton from '../BeerById/BeerPostLikeButton'; import BeerPostLikeButton from '../BeerById/BeerPostLikeButton';

View File

@@ -1,10 +1,11 @@
import UseBeerPostsByBrewery from '@/hooks/data-fetching/beer-posts/useBeerPostsByBrewery'; import UseBeerPostsByBrewery from '@/hooks/data-fetching/beer-posts/useBeerPostsByBrewery';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import Link from 'next/link'; import Link from 'next/link';
import { FC } from 'react'; import { FC, MutableRefObject, useContext, useRef } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { z } from 'zod'; import { z } from 'zod';
import { FaPlus } from 'react-icons/fa'; import { FaPlus } from 'react-icons/fa';
import UserContext from '@/contexts/UserContext';
import BeerRecommendationLoadingComponent from '../BeerById/BeerRecommendationLoadingComponent'; import BeerRecommendationLoadingComponent from '../BeerById/BeerRecommendationLoadingComponent';
interface BreweryCommentsSectionProps { interface BreweryCommentsSectionProps {
@@ -13,6 +14,8 @@ interface BreweryCommentsSectionProps {
const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) => { const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) => {
const PAGE_SIZE = 2; const PAGE_SIZE = 2;
const { user } = useContext(UserContext);
const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = UseBeerPostsByBrewery({ const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = UseBeerPostsByBrewery({
breweryId: breweryPost.id, breweryId: breweryPost.id,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
@@ -28,8 +31,10 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
}, },
}); });
const beerRecommendationsRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
return ( return (
<div className="card"> <div className="card h-full" ref={beerRecommendationsRef}>
<div className="card-body"> <div className="card-body">
<> <>
<div className="my-2 flex flex-row items-center justify-between"> <div className="my-2 flex flex-row items-center justify-between">
@@ -37,6 +42,7 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
<h3 className="text-3xl font-bold">Brews</h3> <h3 className="text-3xl font-bold">Brews</h3>
</div> </div>
<div> <div>
{user && (
<Link <Link
className={`btn-ghost btn-sm btn gap-2 rounded-2xl outline`} className={`btn-ghost btn-sm btn gap-2 rounded-2xl outline`}
href={`/breweries/${breweryPost.id}/beers/create`} href={`/breweries/${breweryPost.id}/beers/create`}
@@ -44,6 +50,7 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
<FaPlus className="text-xl" /> <FaPlus className="text-xl" />
Add Beer Add Beer
</Link> </Link>
)}
</div> </div>
</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 BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import { FC, MutableRefObject, useContext, useRef } from 'react'; import { FC, MutableRefObject, useContext, useRef } from 'react';
import { z } from 'zod'; import { z } from 'zod';
@@ -9,6 +9,7 @@ import APIResponseValidationSchema from '@/validation/APIResponseValidationSchem
import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult'; import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult';
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments'; import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import ToastContext from '@/contexts/ToastContext';
import LoadingComponent from '../BeerById/LoadingComponent'; import LoadingComponent from '../BeerById/LoadingComponent';
import CommentsComponent from '../ui/CommentsComponent'; import CommentsComponent from '../ui/CommentsComponent';
import CommentForm from '../ui/CommentForm'; import CommentForm from '../ui/CommentForm';
@@ -63,6 +64,7 @@ const BreweryCommentForm: FC<BreweryCommentFormProps> = ({ breweryPost, mutate }
resolver: zodResolver(CreateCommentValidationSchema), resolver: zodResolver(CreateCommentValidationSchema),
}); });
const { toast } = useContext(ToastContext);
const onSubmit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async ( const onSubmit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
data, data,
) => { ) => {
@@ -72,6 +74,7 @@ const BreweryCommentForm: FC<BreweryCommentFormProps> = ({ breweryPost, mutate }
breweryPostId: breweryPost.id, breweryPostId: breweryPost.id,
}); });
await mutate(); await mutate();
toast.loading('Created new comment.');
reset(); 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 useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount';
import useTimeDistance from '@/hooks/utilities/useTimeDistance'; import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; 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 useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import { FC, useContext } from 'react'; import { FC, useContext } from 'react';

View File

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

View File

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

View File

@@ -49,9 +49,10 @@ const CommentsComponent: FC<CommentsComponentProps> = ({
handleEditRequest, handleEditRequest,
}) => { }) => {
const { ref: penultimateCommentRef } = useInView({ const { ref: penultimateCommentRef } = useInView({
threshold: 0.1,
/** /**
* When the second last comment comes into view, call setSize from useBeerPostComments * When the last comment comes into view, call setSize from useBeerPostComments to
* to load more comments. * load more comments.
*/ */
onChange: (visible) => { onChange: (visible) => {
if (!visible || isAtEnd) return; if (!visible || isAtEnd) return;
@@ -62,9 +63,9 @@ const CommentsComponent: FC<CommentsComponentProps> = ({
return ( return (
<> <>
{!!comments.length && ( {!!comments.length && (
<div className="card bg-base-300 pb-6"> <div className="card h-full bg-base-300 pb-6">
{comments.map((comment, index) => { {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 * 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 ( return (
<div <div
ref={isPenultimateComment ? penultimateCommentRef : undefined} ref={isLastComment ? penultimateCommentRef : undefined}
key={comment.id} key={comment.id}
> >
<CommentCardBody <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_TOKEN_NAME: z.string(),
SESSION_MAX_AGE: z.coerce.number().positive(), SESSION_MAX_AGE: z.coerce.number().positive(),
POSTGRES_URL: z.string().url(),
POSTGRES_PRISMA_URL: z.string().url(), POSTGRES_PRISMA_URL: z.string().url(),
POSTGRES_URL_NON_POOLING: 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(), SHADOW_DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']), 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; 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 * @example
* 'postgresql://user:password@host:5432/database'; * 'postgresql://user:password@host:5432/database';
* *
* @see https://vercel.com/dashboard/stores * @see https://neon.tech/docs/guides/prisma
*/
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
*/ */
export const POSTGRES_PRISMA_URL = parsed.data.POSTGRES_PRISMA_URL; 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 * @example
* 'postgresql://user:password@host:5432/database'; * '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; export const POSTGRES_URL_NON_POOLING = parsed.data.POSTGRES_URL_NON_POOLING;
/** /**
* The PostgreSQL user from Vercel. * The URL of another Neon PostgreSQL database to shadow for migrations.
*
* @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.
* *
* @example * @example
* 'postgresql://user:password@host:5432/database'; * 'postgresql://user:password@host:5432/database';
*
* @see https://neon.tech/docs/guides/prisma-migrate
*/ */
export const SHADOW_DATABASE_URL = parsed.data.SHADOW_DATABASE_URL; 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 { useRouter } from 'next/router';
import { useContext } from 'react'; 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 APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { useContext } from 'react'; import { useContext } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';

View File

@@ -49,6 +49,7 @@ const useBeerPosts = ({ pageSize }: { pageSize: number }) => {
const { data, error, isLoading, setSize, size } = useSWRInfinite( const { data, error, isLoading, setSize, size } = useSWRInfinite(
(index) => `/api/beers?page_num=${index + 1}&page_size=${pageSize}`, (index) => `/api/beers?page_num=${index + 1}&page_size=${pageSize}`,
fetcher, fetcher,
{ parallel: true },
); );
const beerPosts = data?.flatMap((d) => d.beerPosts) ?? []; 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 APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { useContext } from 'react'; import { useContext } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';

View File

@@ -46,6 +46,7 @@ const useBreweryPosts = ({ pageSize }: { pageSize: number }) => {
const { data, error, isLoading, setSize, size } = useSWRInfinite( const { data, error, isLoading, setSize, size } = useSWRInfinite(
(index) => `/api/breweries?page_num=${index + 1}&page_size=${pageSize}`, (index) => `/api/breweries?page_num=${index + 1}&page_size=${pageSize}`,
fetcher, fetcher,
{ parallel: true },
); );
const breweryPosts = data?.flatMap((d) => d.breweryPosts) ?? []; 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 { useRouter } from 'next/router';
import { useState, useEffect, useContext } from 'react'; import { useState, useEffect, useContext } from 'react';
@@ -34,8 +34,8 @@ const useNavbar = () => {
/** These pages are accessible to both authenticated and unauthenticated users. */ /** These pages are accessible to both authenticated and unauthenticated users. */
const otherPages: readonly Page[] = [ const otherPages: readonly Page[] = [
{ slug: '/breweries', name: 'Breweries' },
{ slug: '/beers', name: 'Beers' }, { 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 '@/styles/globals.css';
import type { AppProps } from 'next/app'; import type { AppProps } from 'next/app';
@@ -11,6 +11,7 @@ import { Space_Grotesk } from 'next/font/google';
import Head from 'next/head'; import Head from 'next/head';
import Layout from '@/components/ui/Layout'; import Layout from '@/components/ui/Layout';
import useUser from '@/hooks/auth/useUser'; import useUser from '@/hooks/auth/useUser';
import CustomToast from '@/components/ui/CustomToast';
const spaceGrotesk = Space_Grotesk({ const spaceGrotesk = Space_Grotesk({
subsets: ['latin'], subsets: ['latin'],
@@ -39,9 +40,12 @@ export default function App({ Component, pageProps }: AppProps) {
</Head> </Head>
<UserContext.Provider value={{ user, isLoading, error, mutate }}> <UserContext.Provider value={{ user, isLoading, error, mutate }}>
<Layout> <Layout>
<CustomToast>
<Component {...pageProps} /> <Component {...pageProps} />
</CustomToast>
</Layout> </Layout>
</UserContext.Provider> </UserContext.Provider>
<Analytics /> <Analytics />
</> </>
); );

View File

@@ -1,120 +1,17 @@
import withPageAuthRequired from '@/util/withPageAuthRequired'; import withPageAuthRequired from '@/util/withPageAuthRequired';
import { NextPage } from 'next'; import { NextPage } from 'next';
import { FC, useState } from 'react'; import { Tab } from '@headlessui/react';
import { Switch, Tab } from '@headlessui/react';
import Head from 'next/head'; 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 GetUserSchema from '@/services/User/schema/GetUserSchema';
import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import AccountInfo from '@/components/Account/AccountInfo';
interface AccountPageProps { interface AccountPageProps {
user: z.infer<typeof GetUserSchema>; 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 }) => { const AccountPage: NextPage<AccountPageProps> = ({ user }) => {
return ( return (
<> <>
@@ -126,7 +23,7 @@ const AccountPage: NextPage<AccountPageProps> = ({ user }) => {
/> />
</Head> </Head>
<div className="flex h-full flex-col items-center bg-base-300"> <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="flex flex-col items-center space-y-3">
<div className="avatar"> <div className="avatar">
<div className="bg-base-black w-24 rounded-full bg-slate-700" /> <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"> <div className="w-full">
<Tab.Group> <Tab.Group>
<Tab.List className="tabs tabs-boxed items-center justify-center rounded-2xl"> <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 Account Info
</Tab> </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 Your Posts
</Tab> </Tab>
</Tab.List> </Tab.List>

View File

@@ -4,8 +4,9 @@ import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import ServerError from '@/config/util/ServerError'; import ServerError from '@/config/util/ServerError';
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import findBeerCommentById from '@/services/BeerComment/findBeerCommentById';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema'; import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import editBeerCommentById from '@/services/BeerComment/editBeerCommentById';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { createRouter, NextHandler } from 'next-connect'; import { createRouter, NextHandler } from 'next-connect';
@@ -27,9 +28,7 @@ const checkIfCommentOwner = async (
) => { ) => {
const { id } = req.query; const { id } = req.query;
const user = req.user!; const user = req.user!;
const comment = await DBClient.instance.beerComment.findUnique({ const comment = await findBeerCommentById(id);
where: { id },
});
if (!comment) { if (!comment) {
throw new ServerError('Comment not found', 404); throw new ServerError('Comment not found', 404);
@@ -48,13 +47,10 @@ const editComment = async (
) => { ) => {
const { id } = req.query; const { id } = req.query;
const updated = await DBClient.instance.beerComment.update({ const updated = await editBeerCommentById({
where: { id },
data: {
content: req.body.content, content: req.body.content,
rating: req.body.rating, rating: req.body.rating,
updatedAt: new Date(), id,
},
}); });
return res.status(200).json({ 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 NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { UserExtendedNextApiRequest } from '@/config/auth/types'; import { UserExtendedNextApiRequest } from '@/config/auth/types';
@@ -14,6 +12,8 @@ import { NextApiResponse } from 'next';
import { z } from 'zod'; import { z } from 'zod';
import ServerError from '@/config/util/ServerError'; import ServerError from '@/config/util/ServerError';
import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import processImageDataIntoDB from '@/services/BeerImage/processImageDataIntoDB';
import ImageMetadataValidationSchema from '@/services/types/ImageSchema/ImageMetadataValidationSchema';
const { storage } = cloudinaryConfig; const { storage } = cloudinaryConfig;
@@ -34,15 +34,10 @@ const uploadMiddleware = expressWrapper(
), ),
); );
const BeerPostImageValidationSchema = z.object({
caption: z.string(),
alt: z.string(),
});
interface UploadBeerPostImagesRequest extends UserExtendedNextApiRequest { interface UploadBeerPostImagesRequest extends UserExtendedNextApiRequest {
files?: Express.Multer.File[]; files?: Express.Multer.File[];
query: { id: string }; query: { id: string };
body: z.infer<typeof BeerPostImageValidationSchema>; body: z.infer<typeof ImageMetadataValidationSchema>;
} }
const processImageData = async ( const processImageData = async (
@@ -54,24 +49,15 @@ const processImageData = async (
if (!files || !files.length) { if (!files || !files.length) {
throw new ServerError('No images uploaded', 400); throw new ServerError('No images uploaded', 400);
} }
const beerImagePromises: Promise<BeerImage>[] = [];
files.forEach((file) => { const beerImages = await processImageDataIntoDB({
beerImagePromises.push(
DBClient.instance.beerImage.create({
data: {
alt: body.alt, alt: body.alt,
postedBy: { connect: { id: user!.id } },
beerPost: { connect: { id: req.query.id } },
path: file.path,
caption: body.caption, caption: body.caption,
}, beerPostId: req.query.id,
}), userId: user!.id,
); files,
}); });
const beerImages = await Promise.all(beerImagePromises);
res.status(200).json({ res.status(200).json({
success: true, success: true,
message: `Successfully uploaded ${beerImages.length} image${ message: `Successfully uploaded ${beerImages.length} image${
@@ -90,7 +76,7 @@ router.post(
getCurrentUser, getCurrentUser,
// @ts-expect-error // @ts-expect-error
uploadMiddleware, uploadMiddleware,
validateRequest({ bodySchema: BeerPostImageValidationSchema }), validateRequest({ bodySchema: ImageMetadataValidationSchema }),
processImageData, processImageData,
); );

View File

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

View File

@@ -2,25 +2,20 @@ import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import { UserExtendedNextApiRequest } from '@/config/auth/types'; import { UserExtendedNextApiRequest } from '@/config/auth/types';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import DBClient from '@/prisma/DBClient';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { createRouter } from 'next-connect'; import { createRouter } from 'next-connect';
import { z } from 'zod'; import { z } from 'zod';
import findBeerPostLikeById from '@/services/BeerPostLike/findBeerPostLikeById';
const checkIfLiked = async ( const checkIfLiked = async (
req: UserExtendedNextApiRequest, req: UserExtendedNextApiRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>, res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => { ) => {
const user = req.user!; 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({ const alreadyLiked = await findBeerPostLikeById({ beerPostId, likedById: user.id });
where: {
beerPostId: id,
likedById: user.id,
},
});
res.status(200).json({ res.status(200).json({
success: true, success: true,
@@ -37,11 +32,7 @@ const router = createRouter<
router.get( router.get(
getCurrentUser, getCurrentUser,
validateRequest({ validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }),
querySchema: z.object({
id: z.string().uuid(),
}),
}),
checkIfLiked, 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({ await DBClient.instance.breweryPost.findMany({
select: { select: {
location: { location: {
select: { coordinates: true, city: true, country: true, stateOrProvince: true }, select: {
coordinates: true,
city: true,
country: true,
stateOrProvince: true,
},
}, },
id: true, id: true,
name: 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, success: true,
payload: { emailIsTaken: !!email }, payload: { emailIsTaken: !!email },
statusCode: 200, 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 ServerError from '@/config/util/ServerError';
import { createRouter } from 'next-connect'; import { createRouter } from 'next-connect';
import createNewUser from '@/services/User/createNewUser'; 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 NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import findUserByUsername from '@/services/User/findUserByUsername'; import findUserByUsername from '@/services/User/findUserByUsername';
import findUserByEmail from '@/services/User/findUserByEmail'; import findUserByEmail from '@/services/User/findUserByEmail';

View File

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

View File

@@ -9,7 +9,7 @@ import { FaArrowUp } from 'react-icons/fa';
import LoadingCard from '@/components/ui/LoadingCard'; import LoadingCard from '@/components/ui/LoadingCard';
const BeerPage: NextPage = () => { const BeerPage: NextPage = () => {
const PAGE_SIZE = 6; const PAGE_SIZE = 20;
const { beerPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } = useBeerPosts({ const { beerPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } = useBeerPosts({
pageSize: PAGE_SIZE, 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 { Carousel } from 'react-responsive-carousel';
import useMediaQuery from '@/hooks/utilities/useMediaQuery'; import useMediaQuery from '@/hooks/utilities/useMediaQuery';
import { Tab } from '@headlessui/react'; import { Tab } from '@headlessui/react';
import BreweryInfoHeader from '@/components/BreweryById/BreweryInfoHeader';
import BreweryPostMap from '@/components/BreweryById/BreweryPostMap'; import dynamic from 'next/dynamic';
import BreweryBeersSection from '@/components/BreweryById/BreweryBeerSection';
import BreweryCommentsSection from '@/components/BreweryById/BreweryCommentsSection'; 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 { interface BreweryPageProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>; breweryPost: z.infer<typeof BreweryPostQueryResult>;

View File

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

View File

@@ -1,6 +1,6 @@
import Spinner from '@/components/ui/Spinner'; import Spinner from '@/components/ui/Spinner';
import withPageAuthRequired from '@/util/withPageAuthRequired'; import withPageAuthRequired from '@/util/withPageAuthRequired';
import UserContext from '@/contexts/userContext'; import UserContext from '@/contexts/UserContext';
import { GetServerSideProps, NextPage } from 'next'; import { GetServerSideProps, NextPage } from 'next';
import { useContext } from 'react'; 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 GetUserSchema from '@/services/User/schema/GetUserSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod'; import { z } from 'zod';
@@ -6,9 +6,7 @@ import { z } from 'zod';
async function sendRegisterUserRequest(data: z.infer<typeof CreateUserValidationSchema>) { async function sendRegisterUserRequest(data: z.infer<typeof CreateUserValidationSchema>) {
const response = await fetch('/api/users/register', { const response = await fetch('/api/users/register', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
body: JSON.stringify(data), body: JSON.stringify(data),
}); });

View File

@@ -12,14 +12,14 @@ const validateEmail = async (email: string) => {
} }
const parsedPayload = z const parsedPayload = z
.object({ usernameIsTaken: z.boolean() }) .object({ emailIsTaken: z.boolean() })
.safeParse(parsed.data.payload); .safeParse(parsed.data.payload);
if (!parsedPayload.success) { if (!parsedPayload.success) {
return false; return false;
} }
return !parsedPayload.data.usernameIsTaken; return !parsedPayload.data.emailIsTaken;
}; };
export default validateEmail; 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 DBClient from '@/prisma/DBClient';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import { z } from 'zod'; import { z } from 'zod';
const getBeerRecommendations = async ( interface GetBeerRecommendationsArgs {
beerPost: Pick<z.infer<typeof beerPostQueryResult>, 'type' | 'brewery' | 'id'>, beerPost: z.infer<typeof BeerPostQueryResult>;
) => { pageNum: number;
const beerRecommendations = await DBClient.instance.beerPost.findMany({ 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: { where: {
OR: [{ typeId: beerPost.type.id }, { breweryId: beerPost.brewery.id }], OR: [{ typeId: beerPost.type.id }, { breweryId: beerPost.brewery.id }],
NOT: { id: beerPost.id }, NOT: { id: beerPost.id },
}, },
include: { select: {
beerImages: { select: { id: true, path: true, caption: true, alt: true } }, id: true,
brewery: { select: { id: true, name: 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 },
}, },
}); });
return beerRecommendations; return { beerRecommendations, count };
}; };
export default getBeerRecommendations; export default getBeerRecommendations;

View File

@@ -1,6 +1,14 @@
import DBClient from '@/prisma/DBClient'; 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 } }); DBClient.instance.beerPostLike.findFirst({ where: { beerPostId, likedById } });
export default findBeerPostLikeById; export default findBeerPostLikeById;

View File

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

View File

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

View File

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