Update: add more toast notifications, update position

Also set Account page to use UserContext. Refactored api requests out of components.
This commit is contained in:
Aaron William Po
2023-05-22 22:41:37 -04:00
parent 27e72d3dcf
commit 4c30af27b6
16 changed files with 242 additions and 188 deletions

View File

@@ -1,24 +1,23 @@
import validateEmailRequest from '@/requests/User/validateEmailRequest';
import validateUsernameRequest from '@/requests/validateUsernameRequest';
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 { FC, useContext, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import UserContext from '@/contexts/UserContext';
import sendEditUserRequest from '@/requests/User/sendEditUserRequest';
import createErrorToast from '@/util/createErrorToast';
import { toast } from 'react-hot-toast';
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 = () => {
const { user, mutate } = useContext(UserContext);
const AccountInfo: FC<AccountInfoProps> = ({ user }) => {
const router = useRouter();
const EditUserSchema = BaseCreateUserSchema.pick({
username: true,
email: true,
@@ -30,7 +29,7 @@ const AccountInfo: FC<AccountInfoProps> = ({ user }) => {
.email({ message: 'Email must be a valid email address.' })
.refine(
async (email) => {
if (user.email === email) return true;
if (user!.email === email) return true;
return validateEmailRequest(email);
},
{ message: 'Email is already taken.' },
@@ -41,7 +40,7 @@ const AccountInfo: FC<AccountInfoProps> = ({ user }) => {
.max(20, { message: 'Username must be less than 20 characters.' })
.refine(
async (username) => {
if (user.username === username) return true;
if (user!.username === username) return true;
return validateUsernameRequest(username);
},
{ message: 'Username is already taken.' },
@@ -53,29 +52,29 @@ const AccountInfo: FC<AccountInfoProps> = ({ user }) => {
>({
resolver: zodResolver(EditUserSchema),
defaultValues: {
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
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.');
const loadingToast = toast.loading('Submitting edits...');
try {
await sendEditUserRequest({ user: user!, data });
await mutate!();
setInEditMode(false);
toast.remove(loadingToast);
toast.success('Edits submitted successfully.');
} catch (error) {
setInEditMode(false);
toast.remove(loadingToast);
createErrorToast(error);
await mutate!();
}
await response.json();
router.reload();
};
return (

View File

@@ -2,7 +2,8 @@ import UserContext from '@/contexts/UserContext';
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import { format } from 'date-fns';
import { Dispatch, FC, SetStateAction, useContext } from 'react';
import { Link, Rating } from 'react-daisyui';
import { Rating } from 'react-daisyui';
import Link from 'next/link';
import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult';
import { z } from 'zod';

View File

@@ -8,6 +8,7 @@ import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResul
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import toast from 'react-hot-toast';
import createErrorToast from '@/util/createErrorToast';
import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo';
import FormLabel from '../ui/forms/FormLabel';
@@ -31,7 +32,6 @@ interface EditCommentBodyProps {
const EditCommentBody: FC<EditCommentBodyProps> = ({
comment,
setInEditMode,
mutate,
handleDeleteRequest,
handleEditRequest,
@@ -43,24 +43,41 @@ const EditCommentBody: FC<EditCommentBodyProps> = ({
resolver: zodResolver(CreateCommentValidationSchema),
});
const { errors } = formState;
const { errors, isSubmitting } = formState;
const [isDeleting, setIsDeleting] = useState(false);
const onDelete = async () => {
const loadingToast = toast.loading('Deleting comment...');
setIsDeleting(true);
try {
await handleDeleteRequest(comment.id);
await mutate();
toast.remove(loadingToast);
toast.success('Deleted comment.');
} catch (error) {
toast.remove(loadingToast);
createErrorToast(error);
}
};
const onEdit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
data,
) => {
const loadingToast = toast.loading('Submitting comment edits...');
try {
setInEditMode(true);
await handleEditRequest(comment.id, data);
await mutate();
toast.success('Submitted edits');
toast.remove(loadingToast);
toast.success('Comment edits submitted successfully.');
setInEditMode(false);
} catch (error) {
toast.remove(loadingToast);
createErrorToast(error);
setInEditMode(false);
}
};
return (
@@ -78,7 +95,7 @@ const EditCommentBody: FC<EditCommentBodyProps> = ({
placeholder="Comment"
rows={2}
error={!!errors.content?.message}
disabled={formState.isSubmitting || isDeleting}
disabled={isSubmitting || isDeleting}
/>
</FormSegment>
<div className="flex flex-row items-center justify-between">
@@ -97,8 +114,8 @@ const EditCommentBody: FC<EditCommentBodyProps> = ({
<Rating.Item
name="rating-1"
className="mask mask-star cursor-default"
disabled={formState.isSubmitting || isDeleting}
aria-disabled={formState.isSubmitting || isDeleting}
disabled={isSubmitting || isDeleting}
aria-disabled={isSubmitting || isDeleting}
key={index}
/>
))}
@@ -108,7 +125,7 @@ const EditCommentBody: FC<EditCommentBodyProps> = ({
<button
type="button"
className="btn-xs btn lg:btn-sm"
disabled={formState.isSubmitting || isDeleting}
disabled={isSubmitting || isDeleting}
onClick={() => {
setInEditMode(false);
}}
@@ -117,7 +134,7 @@ const EditCommentBody: FC<EditCommentBodyProps> = ({
</button>
<button
type="submit"
disabled={formState.isSubmitting || isDeleting}
disabled={isSubmitting || isDeleting}
className="btn-xs btn lg:btn-sm"
>
Save

View File

@@ -10,6 +10,7 @@ import { z } from 'zod';
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import toast from 'react-hot-toast';
import createErrorToast from '@/util/createErrorToast';
import CommentForm from '../ui/CommentForm';
interface BeerCommentFormProps {
@@ -31,20 +32,21 @@ const BeerCommentForm: FunctionComponent<BeerCommentFormProps> = ({
const onSubmit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
data,
) => {
const loadingToast = toast.loading('Posting a new comment...');
try {
await sendCreateBeerCommentRequest({
content: data.content,
rating: data.rating,
beerPostId: beerPost.id,
});
await mutate();
reset();
toast.success('Created a new comment!');
toast.remove(loadingToast);
toast.success('Comment posted successfully.');
await mutate();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Something went wrong.';
toast.error(errorMessage);
await mutate();
toast.remove(loadingToast);
createErrorToast(error);
reset();
}
};

View File

@@ -67,8 +67,8 @@ const BeerPostCommentsSection: FC<BeerPostCommentsSectionProps> = ({ beerPost })
{
/**
* If the comments are loading, show a loading component. Otherwise, show the
* comments.
* If the comments are loading, show a loading component. Otherwise, show
* the comments.
*/
isLoading ? (
<div className="card bg-base-300 pb-6">

View File

@@ -19,8 +19,8 @@ const BeerRecommendationsSection: FC<{
const { ref: penultimateBeerPostRef } = useInView({
/**
* When the last beer post comes into view, call setSize from useBeerPostsByBrewery to
* load more beer posts.
* When the last beer post comes into view, call setSize from
* useBeerPostsByBrewery to load more beer posts.
*/
onChange: (visible) => {
if (!visible || isAtEnd) return;
@@ -46,8 +46,9 @@ const BeerRecommendationsSection: FC<{
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.
* 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 (
@@ -85,8 +86,8 @@ const BeerRecommendationsSection: FC<{
{
/**
* If there are more beer posts to load, show a loading component with a
* skeleton loader and a loading spinner.
* 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} />

View File

@@ -22,8 +22,8 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
});
const { ref: penultimateBeerPostRef } = useInView({
/**
* When the last beer post comes into view, call setSize from useBeerPostsByBrewery to
* load more beer posts.
* When the last beer post comes into view, call setSize from
* useBeerPostsByBrewery to load more beer posts.
*/
onChange: (visible) => {
if (!visible || isAtEnd) return;
@@ -60,8 +60,9 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
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.
* 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 (
@@ -90,8 +91,8 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
{
/**
* If there are more beer posts to load, show a loading component with a
* skeleton loader and a loading spinner.
* 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} />

View File

@@ -0,0 +1,60 @@
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import { FC } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import toast from 'react-hot-toast';
import { z } from 'zod';
import sendCreateBreweryCommentRequest from '@/requests/BreweryComment/sendCreateBreweryCommentRequest';
import createErrorToast from '@/util/createErrorToast';
import CommentForm from '../ui/CommentForm';
interface BreweryCommentFormProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>;
mutate: ReturnType<typeof useBreweryPostComments>['mutate'];
}
const BreweryCommentForm: FC<BreweryCommentFormProps> = ({ breweryPost, mutate }) => {
const { register, handleSubmit, formState, watch, reset, setValue } = useForm<
z.infer<typeof CreateCommentValidationSchema>
>({
defaultValues: { rating: 0 },
resolver: zodResolver(CreateCommentValidationSchema),
});
const onSubmit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
data,
) => {
const loadingToast = toast.loading('Posting a new comment...');
try {
await sendCreateBreweryCommentRequest({
content: data.content,
rating: data.rating,
breweryPostId: breweryPost.id,
});
reset();
toast.remove(loadingToast);
toast.success('Comment posted successfully.');
await mutate();
} catch (error) {
await mutate();
toast.remove(loadingToast);
createErrorToast(error);
reset();
}
};
return (
<CommentForm
handleSubmit={handleSubmit}
onSubmit={onSubmit}
watch={watch}
setValue={setValue}
formState={formState}
register={register}
/>
);
};
export default BreweryCommentForm;

View File

@@ -3,92 +3,16 @@ import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQuer
import { FC, MutableRefObject, useContext, useRef } from 'react';
import { z } from 'zod';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, SubmitHandler } from 'react-hook-form';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult';
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import toast from 'react-hot-toast';
import LoadingComponent from '../BeerById/LoadingComponent';
import CommentsComponent from '../ui/CommentsComponent';
import CommentForm from '../ui/CommentForm';
import BreweryCommentForm from './BreweryCommentForm';
interface BreweryBeerSectionProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>;
}
interface BreweryCommentFormProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>;
mutate: ReturnType<typeof useBreweryPostComments>['mutate'];
}
const BreweryCommentValidationSchemaWithId = CreateCommentValidationSchema.extend({
breweryPostId: z.string(),
});
const sendCreateBreweryCommentRequest = async ({
content,
rating,
breweryPostId,
}: z.infer<typeof BreweryCommentValidationSchemaWithId>) => {
const response = await fetch(`/api/breweries/${breweryPostId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, rating }),
});
if (!response.ok) {
throw new Error(response.statusText);
}
const data = await response.json();
const parsedResponse = APIResponseValidationSchema.safeParse(data);
if (!parsedResponse.success) {
throw new Error('Invalid API response');
}
const parsedPayload = CommentQueryResult.safeParse(parsedResponse.data.payload);
if (!parsedPayload.success) {
throw new Error('Invalid API response payload');
}
return parsedPayload.data;
};
const BreweryCommentForm: FC<BreweryCommentFormProps> = ({ breweryPost, mutate }) => {
const { register, handleSubmit, formState, watch, reset, setValue } = useForm<
z.infer<typeof CreateCommentValidationSchema>
>({
defaultValues: { rating: 0 },
resolver: zodResolver(CreateCommentValidationSchema),
});
const onSubmit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
data,
) => {
await sendCreateBreweryCommentRequest({
content: data.content,
rating: data.rating,
breweryPostId: breweryPost.id,
});
await mutate();
toast.loading('Created new comment.');
reset();
};
return (
<CommentForm
handleSubmit={handleSubmit}
onSubmit={onSubmit}
watch={watch}
setValue={setValue}
formState={formState}
register={register}
/>
);
};
const BreweryCommentsSection: FC<BreweryBeerSectionProps> = ({ breweryPost }) => {
const { user } = useContext(UserContext);

View File

@@ -32,15 +32,15 @@ const LoginForm = () => {
const { mutate } = useContext(UserContext);
const onSubmit: SubmitHandler<LoginT> = async (data) => {
const id = toast.loading('Logging in.');
const loadingToast = toast.loading('Logging in...');
try {
await sendLoginUserRequest(data);
await mutate!();
toast.remove(id);
toast.remove(loadingToast);
toast.success('Logged in!');
await router.push(`/user/current`);
} catch (error) {
toast.remove(id);
toast.remove(loadingToast);
createErrorToast(error);
reset();
}

View File

@@ -68,8 +68,9 @@ const CommentsComponent: FC<CommentsComponentProps> = ({
const isLastComment = index === comments.length - 1;
/**
* Attach a ref to the last comment in the list. When it comes into view, the
* component will call setSize to load more comments.
* Attach a ref to the last comment in the list. When it comes
* into view, the component will call setSize to load more
* comments.
*/
return (
<div
@@ -88,16 +89,17 @@ const CommentsComponent: FC<CommentsComponentProps> = ({
{
/**
* If there are more comments to load, show a loading component with a
* skeleton loader and a loading spinner.
* If there are more comments to load, show a loading component
* with a skeleton loader and a loading spinner.
*/
!!isLoadingMore && <LoadingComponent length={pageSize} />
}
{
/**
* If the user has scrolled to the end of the comments, show a button that
* will scroll them back to the top of the comments section.
* If the user has scrolled to the end of the comments, show a
* button that will scroll them back to the top of the comments
* section.
*/
!!isAtEnd && (
<div className="flex h-20 items-center justify-center text-center">

View File

@@ -22,7 +22,7 @@ const toastToClassName = (toastType: Toast['type']) => {
const CustomToast: FC<{ children: ReactNode }> = ({ children }) => {
return (
<>
<Toaster>
<Toaster position="bottom-center">
{(t) => {
const alertType = toastToClassName(t.type);
return (

View File

@@ -18,8 +18,9 @@ import { useState, useEffect } from 'react';
*/
const useMediaQuery = (query: `(${string})`) => {
/**
* Initialize the matches state variable to false. This is updated whenever the viewport
* size changes (i.e. when the component is mounted and when the window is resized)
* Initialize the matches state variable to false. This is updated whenever the
* viewport size changes (i.e. when the component is mounted and when the window is
* resized)
*/
const [matches, setMatches] = useState(false);
@@ -34,8 +35,8 @@ const useMediaQuery = (query: `(${string})`) => {
}
/**
* Add a resize event listener to the window object, and update the `matches` state
* variable whenever the viewport size changes.
* Add a resize event listener to the window object, and update the `matches`
* state variable whenever the viewport size changes.
*/
const listener = () => setMatches(media.matches);
window.addEventListener('resize', listener);

View File

@@ -3,16 +3,14 @@ import { NextPage } from 'next';
import { Tab } from '@headlessui/react';
import Head from 'next/head';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import { z } from 'zod';
import DBClient from '@/prisma/DBClient';
import AccountInfo from '@/components/Account/AccountInfo';
import { useContext } from 'react';
import UserContext from '@/contexts/UserContext';
interface AccountPageProps {
user: z.infer<typeof GetUserSchema>;
}
const AccountPage: NextPage = () => {
const { user } = useContext(UserContext);
if (!user) return null;
const AccountPage: NextPage<AccountPageProps> = ({ user }) => {
return (
<>
<Head>
@@ -30,7 +28,7 @@ const AccountPage: NextPage<AccountPageProps> = ({ user }) => {
</div>
<div className="flex flex-col items-center space-y-1">
<p className="text-3xl font-bold">Hello, {user.username}!</p>
<p className="text-3xl font-bold">Hello, {user!.username}!</p>
<p className="text-lg">Welcome to your account page.</p>
</div>
</div>
@@ -50,7 +48,7 @@ const AccountPage: NextPage<AccountPageProps> = ({ user }) => {
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<AccountInfo user={user} />
<AccountInfo />
</Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel>
</Tab.Panels>
@@ -64,30 +62,4 @@ const AccountPage: NextPage<AccountPageProps> = ({ user }) => {
export default AccountPage;
export const getServerSideProps = withPageAuthRequired(async (context, session) => {
const { id } = session;
const user: z.infer<typeof GetUserSchema> | null =
await DBClient.instance.user.findUnique({
where: { id },
select: {
username: true,
email: true,
accountIsVerified: true,
firstName: true,
lastName: true,
dateOfBirth: true,
id: true,
createdAt: true,
},
});
if (!user) {
return { redirect: { destination: '/login', permanent: false } };
}
return {
props: {
user: JSON.parse(JSON.stringify(user)),
},
};
});
export const getServerSideProps = withPageAuthRequired();

View File

@@ -0,0 +1,39 @@
import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult';
import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod';
const BreweryCommentValidationSchemaWithId = CreateCommentValidationSchema.extend({
breweryPostId: z.string(),
});
const sendCreateBreweryCommentRequest = async ({
content,
rating,
breweryPostId,
}: z.infer<typeof BreweryCommentValidationSchemaWithId>) => {
const response = await fetch(`/api/breweries/${breweryPostId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, rating }),
});
if (!response.ok) {
throw new Error(response.statusText);
}
const data = await response.json();
const parsedResponse = APIResponseValidationSchema.safeParse(data);
if (!parsedResponse.success) {
throw new Error('Invalid API response');
}
const parsedPayload = CommentQueryResult.safeParse(parsedResponse.data.payload);
if (!parsedPayload.success) {
throw new Error('Invalid API response payload');
}
return parsedPayload.data;
};
export default sendCreateBreweryCommentRequest;

View File

@@ -0,0 +1,35 @@
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod';
interface SendEditUserRequestArgs {
user: z.infer<typeof GetUserSchema>;
data: {
username: string;
email: string;
firstName: string;
lastName: string;
};
}
const sendEditUserRequest = async ({ user, data }: SendEditUserRequestArgs) => {
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(response.statusText);
}
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('API response validation failed.');
}
return parsed;
};
export default sendEditUserRequest;