Begin integration of dotnet backend with biergarten nextjs code

This commit is contained in:
Aaron Po
2026-01-26 18:52:16 -05:00
parent 3d8b17320a
commit 7dc7ef4b1a
345 changed files with 0 additions and 0 deletions

View File

@@ -1,176 +0,0 @@
import validateUsernameRequest from '@/requests/users/profile/validateUsernameRequest';
import { BaseCreateUserSchema } from '@/services/users/auth/schema/CreateUserValidationSchemas';
import { Switch } from '@headlessui/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Dispatch, FC, useContext } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import UserContext from '@/contexts/UserContext';
import createErrorToast from '@/util/createErrorToast';
import { toast } from 'react-hot-toast';
import { AccountPageAction, AccountPageState } from '@/reducers/accountPageReducer';
import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo';
import FormLabel from '../ui/forms/FormLabel';
import FormTextInput from '../ui/forms/FormTextInput';
import { sendEditUserRequest, validateEmailRequest } from '@/requests/users/auth';
interface AccountInfoProps {
pageState: AccountPageState;
dispatch: Dispatch<AccountPageAction>;
}
const AccountInfo: FC<AccountInfoProps> = ({ pageState, dispatch }) => {
const { user, mutate } = useContext(UserContext);
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 validateEmailRequest({ 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 validateUsernameRequest(username);
},
{ message: 'Username is already taken.' },
),
});
const onSubmit = async (data: z.infer<typeof EditUserSchema>) => {
const loadingToast = toast.loading('Submitting edits...');
try {
await sendEditUserRequest({ user: user!, data });
toast.remove(loadingToast);
toast.success('Edits submitted successfully.');
dispatch({ type: 'CLOSE_ALL' });
await mutate!();
} catch (error) {
dispatch({ type: 'CLOSE_ALL' });
toast.remove(loadingToast);
createErrorToast(error);
await mutate!();
}
};
const { register, handleSubmit, formState, reset } = useForm<
z.infer<typeof EditUserSchema>
>({
resolver: zodResolver(EditUserSchema),
});
return (
<div className="card mt-8">
<div className="card-body flex flex-col space-y-3">
<div className="flex w-full items-center justify-between space-x-5">
<div className="">
<h1 className="text-lg font-bold">Edit Your Account Info</h1>
<p>Update your personal account information.</p>
</div>
<div>
<Switch
className="toggle"
id="edit-toggle"
checked={pageState.accountInfoOpen}
onClick={async () => {
dispatch({ type: 'TOGGLE_ACCOUNT_INFO_VISIBILITY' });
await mutate!();
reset({
username: user!.username,
email: user!.email,
firstName: user!.firstName,
lastName: user!.lastName,
});
}}
/>
</div>
</div>
{pageState.accountInfoOpen && (
<form
className="form-control space-y-5"
onSubmit={handleSubmit(onSubmit)}
noValidate
>
<div>
<FormInfo>
<FormLabel htmlFor="username">Username</FormLabel>
<FormError>{formState.errors.username?.message}</FormError>
</FormInfo>
<FormTextInput
type="text"
disabled={!pageState.accountInfoOpen || 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={!pageState.accountInfoOpen || 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={!pageState.accountInfoOpen || 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={!pageState.accountInfoOpen || formState.isSubmitting}
error={!!formState.errors.lastName}
id="lastName"
formValidationSchema={register('lastName')}
/>
</div>
</div>
<button
className="btn btn-primary my-5 w-full"
type="submit"
disabled={!pageState.accountInfoOpen || formState.isSubmitting}
>
Save Changes
</button>
</div>
</form>
)}
</div>
</div>
);
};
export default AccountInfo;

View File

@@ -1,82 +0,0 @@
import UserContext from '@/contexts/UserContext';
import useBeerPostsByUser from '@/hooks/data-fetching/beer-posts/useBeerPostsByUser';
import { FC, useContext, MutableRefObject, useRef } from 'react';
import { FaArrowUp } from 'react-icons/fa';
import { useInView } from 'react-intersection-observer';
import BeerCard from '../BeerIndex/BeerCard';
import LoadingCard from '../ui/LoadingCard';
import Spinner from '../ui/Spinner';
const BeerPostsByUser: FC = () => {
const { user } = useContext(UserContext);
const pageRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
const PAGE_SIZE = 2;
const { beerPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } =
useBeerPostsByUser({ pageSize: PAGE_SIZE, userId: user!.id });
const { ref: lastBeerPostRef } = useInView({
onChange: (visible) => {
if (!visible || isAtEnd) return;
setSize(size + 1);
},
});
return (
<div className="mt-4" ref={pageRef}>
<div className="grid gap-6 xl:grid-cols-2">
{!!beerPosts.length && !isLoading && (
<>
{beerPosts.map((beerPost, i) => {
return (
<div
key={beerPost.id}
ref={beerPosts.length === i + 1 ? lastBeerPostRef : undefined}
>
<BeerCard post={beerPost} />
</div>
);
})}
</>
)}
{isLoadingMore && (
<>
{Array.from({ length: PAGE_SIZE }, (_, i) => (
<LoadingCard key={i} />
))}
</>
)}
</div>
{(isLoading || isLoadingMore) && (
<div className="flex h-32 w-full items-center justify-center">
<Spinner size="sm" />
</div>
)}
{!!beerPosts.length && isAtEnd && !isLoading && (
<div className="flex h-20 items-center justify-center text-center">
<div className="tooltip tooltip-bottom" data-tip="Scroll back to top of page.">
<button
type="button"
className="btn btn-ghost btn-sm"
aria-label="Scroll back to top of page."
onClick={() => {
pageRef.current?.scrollIntoView({
behavior: 'smooth',
});
}}
>
<FaArrowUp />
</button>
</div>
</div>
)}
{!beerPosts.length && !isLoading && (
<div className="flex h-24 w-full items-center justify-center">
<p className="text-lg font-bold">No posts yet.</p>
</div>
)}
</div>
);
};
export default BeerPostsByUser;

View File

@@ -1,84 +0,0 @@
import UserContext from '@/contexts/UserContext';
import { FC, useContext, MutableRefObject, useRef } from 'react';
import { FaArrowUp } from 'react-icons/fa';
import { useInView } from 'react-intersection-observer';
import useBreweryPostsByUser from '@/hooks/data-fetching/brewery-posts/useBreweryPostsByUser';
import LoadingCard from '../ui/LoadingCard';
import Spinner from '../ui/Spinner';
import BreweryCard from '../BreweryIndex/BreweryCard';
const BreweryPostsByUser: FC = () => {
const { user } = useContext(UserContext);
const pageRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
const PAGE_SIZE = 2;
const { breweryPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } =
useBreweryPostsByUser({ pageSize: PAGE_SIZE, userId: user!.id });
const { ref: lastBreweryPostRef } = useInView({
onChange: (visible) => {
if (!visible || isAtEnd) return;
setSize(size + 1);
},
});
return (
<div className="mt-4" ref={pageRef}>
<div className="grid gap-6 xl:grid-cols-2">
{!!breweryPosts.length && !isLoading && (
<>
{breweryPosts.map((breweryPost, i) => {
return (
<div
key={breweryPost.id}
ref={breweryPosts.length === i + 1 ? lastBreweryPostRef : undefined}
>
<BreweryCard brewery={breweryPost} />
</div>
);
})}
</>
)}
{isLoadingMore && (
<>
{Array.from({ length: PAGE_SIZE }, (_, i) => (
<LoadingCard key={i} />
))}
</>
)}
</div>
{(isLoading || isLoadingMore) && (
<div className="flex h-32 w-full items-center justify-center">
<Spinner size="sm" />
</div>
)}
{!!breweryPosts.length && isAtEnd && !isLoading && (
<div className="flex h-20 items-center justify-center text-center">
<div className="tooltip tooltip-bottom" data-tip="Scroll back to top of page.">
<button
type="button"
className="btn btn-ghost btn-sm"
aria-label="Scroll back to top of page."
onClick={() => {
pageRef.current?.scrollIntoView({
behavior: 'smooth',
});
}}
>
<FaArrowUp />
</button>
</div>
</div>
)}
{!breweryPosts.length && !isLoading && (
<div className="flex h-24 w-full items-center justify-center">
<p className="text-lg font-bold">No posts yet.</p>
</div>
)}
</div>
);
};
export default BreweryPostsByUser;

View File

@@ -1,96 +0,0 @@
import UserContext from '@/contexts/UserContext';
import { AccountPageState, AccountPageAction } from '@/reducers/accountPageReducer';
import { Switch } from '@headlessui/react';
import { useRouter } from 'next/router';
import { Dispatch, FunctionComponent, useContext, useRef } from 'react';
import { toast } from 'react-hot-toast';
interface DeleteAccountProps {
pageState: AccountPageState;
dispatch: Dispatch<AccountPageAction>;
}
const DeleteAccount: FunctionComponent<DeleteAccountProps> = ({
dispatch,
pageState,
}) => {
const deleteRef = useRef<null | HTMLDialogElement>(null);
const router = useRouter();
const { user, mutate } = useContext(UserContext);
const onDeleteSubmit = async () => {
deleteRef.current!.close();
const loadingToast = toast.loading(
'Deleting your account. We are sad to see you go. 😭',
);
const request = await fetch(`/api/users/${user?.id}`, {
method: 'DELETE',
});
if (!request.ok) {
throw new Error('Could not delete that user.');
}
toast.remove(loadingToast);
toast.success('Deleted your account. Goodbye. 😓');
await mutate!();
router.push('/');
};
return (
<div className="card w-full space-y-4">
<div className="card-body">
<div className="flex w-full items-center justify-between space-x-5">
<div className="">
<h1 className="text-lg font-bold">Delete Your Account</h1>
<p>Want to leave? Delete your account here.</p>
</div>
<div>
<Switch
className="toggle"
id="edit-toggle"
checked={pageState.deleteAccountOpen}
onClick={() => {
dispatch({ type: 'TOGGLE_DELETE_ACCOUNT_VISIBILITY' });
}}
/>
</div>
</div>
{pageState.deleteAccountOpen && (
<>
<div className="mt-3">
<button
className="btn btn-primary w-full"
onClick={() => deleteRef.current!.showModal()}
>
Delete my account
</button>
<dialog id="delete-modal" className="modal" ref={deleteRef}>
<div className="modal-box text-center">
<h3 className="text-lg font-bold">{`You're about to delete your account.`}</h3>
<p className="">This action is permanent and cannot be reversed.</p>
<div className="modal-action flex-col space-x-0 space-y-3">
<button
className="btn btn-error btn-sm w-full"
onClick={onDeleteSubmit}
>
Okay, delete my account
</button>
<button
className="btn btn-success btn-sm w-full"
onClick={() => deleteRef.current!.close()}
>
Go back
</button>
</div>
</div>
</dialog>
</div>
</>
)}
</div>
</div>
);
};
export default DeleteAccount;

View File

@@ -1,101 +0,0 @@
import { Switch } from '@headlessui/react';
import { Dispatch, FunctionComponent } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { UpdatePasswordSchema } from '@/services/users/auth/schema/CreateUserValidationSchemas';
import { AccountPageState, AccountPageAction } from '@/reducers/accountPageReducer';
import toast from 'react-hot-toast';
import createErrorToast from '@/util/createErrorToast';
import { sendUpdatePasswordRequest } from '@/requests/users/auth';
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 SecurityProps {
pageState: AccountPageState;
dispatch: Dispatch<AccountPageAction>;
}
const Security: FunctionComponent<SecurityProps> = ({ dispatch, pageState }) => {
const { register, handleSubmit, formState, reset } = useForm<
z.infer<typeof UpdatePasswordSchema>
>({
resolver: zodResolver(UpdatePasswordSchema),
});
const onSubmit: SubmitHandler<z.infer<typeof UpdatePasswordSchema>> = async (data) => {
const loadingToast = toast.loading('Changing password.');
try {
await sendUpdatePasswordRequest(data);
toast.remove(loadingToast);
toast.success('Password changed successfully.');
dispatch({ type: 'CLOSE_ALL' });
} catch (error) {
dispatch({ type: 'CLOSE_ALL' });
createErrorToast(error);
}
};
return (
<div className="card w-full space-y-4">
<div className="card-body">
<div className="flex w-full items-center justify-between space-x-5">
<div className="">
<h1 className="text-lg font-bold">Change Your Password</h1>
<p>Update your password to maintain the safety of your account.</p>
</div>
<div>
<Switch
className="toggle"
id="edit-toggle"
checked={pageState.securityOpen}
onClick={() => {
dispatch({ type: 'TOGGLE_SECURITY_VISIBILITY' });
reset();
}}
/>
</div>
</div>
{pageState.securityOpen && (
<form className="form-control" noValidate onSubmit={handleSubmit(onSubmit)}>
<FormInfo>
<FormLabel htmlFor="password">New Password</FormLabel>
<FormError>{formState.errors.password?.message}</FormError>
</FormInfo>
<FormTextInput
type="password"
disabled={!pageState.securityOpen || formState.isSubmitting}
error={!!formState.errors.password}
id="password"
formValidationSchema={register('password')}
/>
<FormInfo>
<FormLabel htmlFor="confirm-password">Confirm Password</FormLabel>
<FormError>{formState.errors.confirmPassword?.message}</FormError>
</FormInfo>
<FormTextInput
type="password"
disabled={!pageState.securityOpen || formState.isSubmitting}
error={!!formState.errors.confirmPassword}
id="confirm-password"
formValidationSchema={register('confirmPassword')}
/>
<button
className="btn btn-primary mt-5"
disabled={!pageState.securityOpen || formState.isSubmitting}
type="submit"
>
Update
</button>
</form>
)}
</div>
</div>
);
};
export default Security;

View File

@@ -1,93 +0,0 @@
import FormError from '@/components/ui/forms/FormError';
import FormInfo from '@/components/ui/forms/FormInfo';
import FormLabel from '@/components/ui/forms/FormLabel';
import FormSegment from '@/components/ui/forms/FormSegment';
import Link from 'next/link';
import FormTextArea from '@/components/ui/forms/FormTextArea';
import { FC } from 'react';
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
import type {
UseFormHandleSubmit,
SubmitHandler,
FieldErrors,
UseFormRegister,
} from 'react-hook-form';
import { z } from 'zod';
import UpdateProfileSchema from '@/services/users/auth/schema/UpdateProfileSchema';
type UpdateProfileSchemaT = z.infer<typeof UpdateProfileSchema>;
interface UpdateProfileFormProps {
handleSubmit: UseFormHandleSubmit<UpdateProfileSchemaT>;
onSubmit: SubmitHandler<UpdateProfileSchemaT>;
errors: FieldErrors<UpdateProfileSchemaT>;
isSubmitting: boolean;
register: UseFormRegister<UpdateProfileSchemaT>;
user: z.infer<typeof GetUserSchema>;
}
const UpdateProfileForm: FC<UpdateProfileFormProps> = ({
handleSubmit,
onSubmit,
errors,
isSubmitting,
register,
user,
}) => {
return (
<form className="form-control space-y-1" noValidate onSubmit={handleSubmit(onSubmit)}>
<div>
<FormInfo>
<FormLabel htmlFor="userAvatar">Avatar</FormLabel>
<FormError>{errors.userAvatar?.message}</FormError>
</FormInfo>
<FormSegment>
<input
disabled={isSubmitting}
type="file"
id="userAvatar"
className="file-input file-input-bordered w-full"
{...register('userAvatar')}
multiple={false}
/>
</FormSegment>
</div>
<div>
<FormInfo>
<FormLabel htmlFor="bio">Bio</FormLabel>
<FormError>{errors.bio?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
disabled={isSubmitting}
id="bio"
{...register('bio')}
rows={5}
formValidationSchema={register('bio')}
error={!!errors.bio}
placeholder="Bio"
/>
</FormSegment>
</div>
<div className="mt-6 flex w-full flex-col justify-center space-y-3">
<Link
className={`btn btn-secondary rounded-xl ${isSubmitting ? 'btn-disabled' : ''}`}
href={`/users/${user?.id}`}
>
Cancel Changes
</Link>
<button
className="btn btn-primary w-full rounded-xl"
type="submit"
disabled={isSubmitting}
>
Save Changes
</button>
</div>
</form>
);
};
export default UpdateProfileForm;

View File

@@ -1,29 +0,0 @@
import Link from 'next/link';
import React from 'react';
import { FaArrowRight } from 'react-icons/fa';
const UpdateProfileLink: React.FC = () => {
return (
<div className="card">
<div className="card-body flex flex-col space-y-3">
<div className="flex w-full items-center justify-between space-x-5">
<div className="">
<h1 className="text-lg font-bold">Update Your Profile</h1>
<p className="text-sm">You can update your profile information here.</p>
</div>
<div>
<Link
href="/users/account/edit-profile"
className="btn-sk btn btn-circle btn-ghost btn-sm"
>
<FaArrowRight className="text-xl" />
</Link>
</div>
</div>
</div>
</div>
);
};
export default UpdateProfileLink;

View File

@@ -1,39 +0,0 @@
import { FC } from 'react';
import { CldImage } from 'next-cloudinary';
import { z } from 'zod';
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
import { FaUser } from 'react-icons/fa';
interface UserAvatarProps {
user: {
username: z.infer<typeof GetUserSchema>['username'];
userAvatar: z.infer<typeof GetUserSchema>['userAvatar'];
id: z.infer<typeof GetUserSchema>['id'];
};
}
const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
const { userAvatar } = user;
return !userAvatar ? (
<div
className="mask mask-circle flex h-32 w-full items-center justify-center bg-primary"
aria-label="Default user avatar"
role="img"
>
<span className="h-full text-2xl font-bold text-base-content">
<FaUser className="h-full" />
</span>
</div>
) : (
<CldImage
src={userAvatar.path}
alt="user avatar"
width={1000}
height={1000}
crop="fill"
className="mask mask-circle h-full w-full object-cover"
/>
);
};
export default UserAvatar;

View File

@@ -1,29 +0,0 @@
import { Tab } from '@headlessui/react';
import { FC } from 'react';
import BeerPostsByUser from './BeerPostsByUser';
import BreweryPostsByUser from './BreweryPostsByUser';
const UserPosts: FC = () => {
return (
<div className="mt-4">
<div>
<Tab.Group>
<Tab.List className="tabs-boxed tabs grid grid-cols-2">
<Tab className="tab uppercase ui-selected:tab-active">Beers</Tab>
<Tab className="tab uppercase ui-selected:tab-active">Breweries</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<BeerPostsByUser />
</Tab.Panel>
<Tab.Panel>
<BreweryPostsByUser />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
);
};
export default UserPosts;

View File

@@ -1,61 +0,0 @@
import BeerPostQueryResult from '@/services/posts/beer-post/schema/BeerPostQueryResult';
import { zodResolver } from '@hookform/resolvers/zod';
import { FunctionComponent } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod';
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
import toast from 'react-hot-toast';
import createErrorToast from '@/util/createErrorToast';
import { sendCreateBeerCommentRequest } from '@/requests/comments/beer-comment';
import CommentForm from '../Comments/CommentForm';
interface BeerCommentFormProps {
beerPost: z.infer<typeof BeerPostQueryResult>;
mutate: ReturnType<typeof useBeerPostComments>['mutate'];
}
const BeerCommentForm: FunctionComponent<BeerCommentFormProps> = ({
beerPost,
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 sendCreateBeerCommentRequest({ body: data, beerPostId: beerPost.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 BeerCommentForm;

View File

@@ -1,109 +0,0 @@
import Link from 'next/link';
import format from 'date-fns/format';
import { FC, useContext } from 'react';
import UserContext from '@/contexts/UserContext';
import { FaRegEdit } from 'react-icons/fa';
import BeerPostQueryResult from '@/services/posts/beer-post/schema/BeerPostQueryResult';
import { z } from 'zod';
import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount';
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import BeerPostLikeButton from './BeerPostLikeButton';
interface BeerInfoHeaderProps {
beerPost: z.infer<typeof BeerPostQueryResult>;
}
const BeerInfoHeader: FC<BeerInfoHeaderProps> = ({ beerPost }) => {
const createdAt = new Date(beerPost.createdAt);
const timeDistance = useTimeDistance(createdAt);
const { user } = useContext(UserContext);
const idMatches = user && beerPost.postedBy.id === user.id;
const isPostOwner = !!(user && idMatches);
const { likeCount, mutate } = useGetBeerPostLikeCount(beerPost.id);
return (
<article className="card flex flex-col justify-center bg-base-300">
<div className="card-body">
<header className="flex justify-between">
<div className="space-y-2">
<div>
<h1 className="text-2xl font-bold lg:text-4xl">{beerPost.name}</h1>
<h2 className="text-lg font-semibold lg:text-2xl">
by{' '}
<Link
href={`/breweries/${beerPost.brewery.id}`}
className="link-hover link font-semibold"
>
{beerPost.brewery.name}
</Link>
</h2>
</div>
<div>
<h3 className="italic">
{' posted by '}
<Link href={`/users/${beerPost.postedBy.id}`} className="link-hover link">
{`${beerPost.postedBy.username} `}
</Link>
{timeDistance && (
<span
className="tooltip tooltip-bottom"
data-tip={format(createdAt, 'MM/dd/yyyy')}
>
{`${timeDistance} ago`}
</span>
)}
</h3>
</div>
</div>
{isPostOwner && (
<div className="tooltip tooltip-left" data-tip={`Edit '${beerPost.name}'`}>
<Link href={`/beers/${beerPost.id}/edit`} className="btn btn-ghost btn-xs">
<FaRegEdit className="text-xl" />
</Link>
</div>
)}
</header>
<div className="space-y-2">
<p>{beerPost.description}</p>
<div className="flex justify-between">
<div className="space-y-1">
<div>
<Link
className="link-hover link text-lg font-bold"
href={`/beers/styles/${beerPost.style.id}`}
>
{beerPost.style.name}
</Link>
</div>
<div>
<span className="mr-4 text-lg font-medium">
{beerPost.abv.toFixed(1)}% ABV
</span>
<span className="text-lg font-medium">{beerPost.ibu.toFixed(1)} IBU</span>
</div>
<div>
{(!!likeCount || likeCount === 0) && (
<span>
Liked by {likeCount}
{likeCount !== 1 ? ' users' : ' user'}
</span>
)}
</div>
</div>
<div className="card-actions items-end">
{user && (
<BeerPostLikeButton beerPostId={beerPost.id} mutateCount={mutate} />
)}
</div>
</div>
</div>
</div>
</article>
);
};
export default BeerInfoHeader;

View File

@@ -1,88 +0,0 @@
import UserContext from '@/contexts/UserContext';
import BeerPostQueryResult from '@/services/posts/beer-post/schema/BeerPostQueryResult';
import { FC, MutableRefObject, useContext, useRef } from 'react';
import { z } from 'zod';
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import { useRouter } from 'next/router';
import {
deleteBeerPostCommentRequest,
editBeerPostCommentRequest,
} from '@/requests/comments/beer-comment';
import BeerCommentForm from './BeerCommentForm';
import CommentLoadingComponent from '../Comments/CommentLoadingComponent';
import CommentsComponent from '../Comments/CommentsComponent';
interface BeerPostCommentsSectionProps {
beerPost: z.infer<typeof BeerPostQueryResult>;
}
const BeerPostCommentsSection: FC<BeerPostCommentsSectionProps> = ({ beerPost }) => {
const { user } = useContext(UserContext);
const router = useRouter();
const pageNum = parseInt(router.query.comments_page as string, 10) || 1;
const PAGE_SIZE = 15;
const { comments, isLoading, mutate, setSize, size, isLoadingMore, isAtEnd } =
useBeerPostComments({ id: beerPost.id, pageNum, pageSize: PAGE_SIZE });
const commentSectionRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
return (
<div className="w-full space-y-3" ref={commentSectionRef}>
<div className="card bg-base-300">
<div className="card-body h-full">
{user ? (
<BeerCommentForm beerPost={beerPost} mutate={mutate} />
) : (
<div className="flex h-52 flex-col items-center justify-center">
<span className="text-lg font-bold">Log in to leave a comment.</span>
</div>
)}
</div>
</div>
{
/**
* If the comments are loading, show a loading component. Otherwise, show the
* comments.
*/
isLoading ? (
<div className="card bg-base-300 pb-6">
<CommentLoadingComponent length={PAGE_SIZE} />
</div>
) : (
<CommentsComponent
commentSectionRef={commentSectionRef}
comments={comments}
isLoadingMore={isLoadingMore}
isAtEnd={isAtEnd}
pageSize={PAGE_SIZE}
setSize={setSize}
size={size}
mutate={mutate}
handleDeleteCommentRequest={(id) => {
return deleteBeerPostCommentRequest({
commentId: id,
beerPostId: beerPost.id,
});
}}
handleEditCommentRequest={(id, data) => {
return editBeerPostCommentRequest({
body: data,
commentId: id,
beerPostId: beerPost.id,
});
}}
/>
)
}
</div>
);
};
export default BeerPostCommentsSection;

View File

@@ -1,35 +0,0 @@
import useCheckIfUserLikesBeerPost from '@/hooks/data-fetching/beer-likes/useCheckIfUserLikesBeerPost';
import { FC, useEffect, useState } from 'react';
import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount';
import sendBeerPostLikeRequest from '@/requests/likes/beer-post-like/sendBeerPostLikeRequest';
import LikeButton from '../ui/LikeButton';
const BeerPostLikeButton: FC<{
beerPostId: string;
mutateCount: ReturnType<typeof useGetBeerPostLikeCount>['mutate'];
}> = ({ beerPostId, mutateCount }) => {
const { isLiked, mutate: mutateLikeStatus } = useCheckIfUserLikesBeerPost(beerPostId);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(false);
}, [isLiked]);
const handleLike = async () => {
try {
setLoading(true);
await sendBeerPostLikeRequest(beerPostId);
await Promise.all([mutateCount(), mutateLikeStatus()]);
setLoading(false);
} catch (e) {
setLoading(false);
}
};
return <LikeButton isLiked={!!isLiked} handleLike={handleLike} loading={loading} />;
};
export default BeerPostLikeButton;

View File

@@ -1,33 +0,0 @@
import { FC } from 'react';
import Spinner from '../ui/Spinner';
interface BeerRecommendationLoadingComponentProps {
length: number;
}
const BeerRecommendationLoadingComponent: FC<BeerRecommendationLoadingComponentProps> = ({
length,
}) => {
return (
<>
{Array.from({ length }).map((_, i) => (
<div className="animate my-3 fade-in-10" key={i}>
<div className="flex animate-pulse space-x-4">
<div className="flex-1 space-y-4 py-1">
<div className="h-4 w-3/4 rounded bg-base-100" />
<div className="space-y-2">
<div className="h-4 rounded bg-base-100" />
<div className="h-4 w-11/12 rounded bg-base-100" />
</div>
</div>
</div>
</div>
))}
<div className="p-1">
<Spinner size="sm" />
</div>
</>
);
};
export default BeerRecommendationLoadingComponent;

View File

@@ -1,106 +0,0 @@
import Link from 'next/link';
import { FC } 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/posts/beer-post/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)();
},
});
return (
<div className="card h-full">
<div className="card-body">
<>
<div className="my-2 flex flex-row items-center justify-between">
<div>
<h3 className="text-3xl font-bold">Also check out</h3>
</div>
</div>
{!!beerPosts.length && (
<div className="space-y-5">
{beerPosts.map((post, index) => {
const isPenultimateBeerPost = index === beerPosts.length - 2;
/**
* Attach a ref to the second last beer post in the list. When it comes
* into view, the component will call setSize to load more beer posts.
*/
return (
<div
ref={isPenultimateBeerPost ? penultimateBeerPostRef : undefined}
key={post.id}
className="animate-fade"
>
<div className="flex flex-col">
<Link className="link-hover link" href={`/beers/${post.id}`}>
<span className="text-xl font-bold">{post.name}</span>
</Link>
<Link
className="link-hover link"
href={`/breweries/${post.brewery.id}`}
>
<span className="text-lg font-semibold">{post.brewery.name}</span>
</Link>
</div>
<div>
<div>
<Link
className="link-hover link"
href={`/beers/styles/${post.style.id}`}
>
<span className="font-medium">{post.style.name}</span>
</Link>
</div>
<div className="space-x-2">
<span>{post.abv.toFixed(1)}% ABV</span>
<span>{post.ibu.toFixed(1)} IBU</span>
</div>
</div>
</div>
);
})}
</div>
)}
{
/**
* If there are more beer posts to load, show a loading component with a
* skeleton loader and a loading spinner.
*/
!!isLoadingMore && !isAtEnd && (
<BeerRecommendationLoadingComponent length={PAGE_SIZE} />
)
}
</>
</div>
</div>
);
};
export default BeerRecommendationsSection;

View File

@@ -1,73 +0,0 @@
import Link from 'next/link';
import { FC, useContext } from 'react';
import BeerPostQueryResult from '@/services/posts/beer-post/schema/BeerPostQueryResult';
import { z } from 'zod';
import UserContext from '@/contexts/UserContext';
import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount';
import { CldImage } from 'next-cloudinary';
import BeerPostLikeButton from '../BeerById/BeerPostLikeButton';
const BeerCard: FC<{ post: z.infer<typeof BeerPostQueryResult> }> = ({ post }) => {
const { user } = useContext(UserContext);
const { mutate, likeCount, isLoading } = useGetBeerPostLikeCount(post.id);
return (
<div className="card card-compact bg-base-300" key={post.id}>
<figure className="h-96">
<Link href={`/beers/${post.id}`} className="h-full object-cover">
{post.beerImages.length > 0 && (
<CldImage
src={post.beerImages[0].path}
alt={post.name}
crop="fill"
width="3000"
height="3000"
className="h-full object-cover"
/>
)}
</Link>
</figure>
<div className="card-body justify-between">
<div className="space-y-1">
<Link href={`/beers/${post.id}`}>
<h3 className="link-hover link overflow-hidden whitespace-normal text-2xl font-bold lg:truncate lg:text-3xl">
{post.name}
</h3>
</Link>
<Link href={`/breweries/${post.brewery.id}`}>
<h4 className="text-md link-hover link whitespace-normal lg:truncate lg:text-xl">
{post.brewery.name}
</h4>
</Link>
</div>
<div className="flex items-end justify-between">
<div>
<Link
className="text-md hover:underline lg:text-xl"
href={`/beers/styles/${post.style.id}`}
>
{post.style.name}
</Link>
<div className="space-x-3">
<span className="text-sm lg:text-lg">{post.abv.toFixed(1)}% ABV</span>
<span className="text-sm lg:text-lg">{post.ibu.toFixed(1)} IBU</span>
</div>
{!isLoading && (
<span>
liked by {likeCount} user{likeCount === 1 ? '' : 's'}
</span>
)}
</div>
<div>
{!!user && !isLoading && (
<BeerPostLikeButton beerPostId={post.id} mutateCount={mutate} />
)}
</div>
</div>
</div>
</div>
);
};
export default BeerCard;

View File

@@ -1,100 +0,0 @@
import Link from 'next/link';
import { FC, MutableRefObject, useRef } from 'react';
import { useInView } from 'react-intersection-observer';
import { z } from 'zod';
import BeerStyleQueryResult from '@/services/posts/beer-style-post/schema/BeerStyleQueryResult';
import useBeerPostsByBeerStyle from '@/hooks/data-fetching/beer-posts/useBeerPostsByBeerStyles';
import BeerRecommendationLoadingComponent from '../BeerById/BeerRecommendationLoadingComponent';
interface BeerStyleBeerSectionProps {
beerStyle: z.infer<typeof BeerStyleQueryResult>;
}
const BeerStyleBeerSection: FC<BeerStyleBeerSectionProps> = ({ beerStyle }) => {
const PAGE_SIZE = 2;
const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = useBeerPostsByBeerStyle({
beerStyleId: beerStyle.id,
pageSize: PAGE_SIZE,
});
const { ref: penultimateBeerPostRef } = useInView({
/**
* When the last beer post comes into view, call setSize from useBeerPostsByBeerStyle
* to load more beer posts.
*/
onChange: (visible) => {
if (!visible || isAtEnd) return;
setSize(size + 1);
},
});
const beerRecommendationsRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
return (
<div className="card h-full" ref={beerRecommendationsRef}>
<div className="card-body">
<>
<div className="my-2 flex flex-row items-center justify-between">
<div>
<h3 className="text-3xl font-bold">Brews</h3>
</div>
</div>
{!!beerPosts.length && (
<div className="space-y-5">
{beerPosts.map((beerPost, 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={beerPost.id}
>
<div>
<Link className="link-hover link" href={`/beers/${beerPost.id}`}>
<span className="text-xl font-semibold">{beerPost.name}</span>
</Link>
</div>
<div>
<Link
className="link-hover link"
href={`/breweries/${beerPost.brewery.id}`}
>
<span className="text-xl font-semibold">
{beerPost.brewery.name}
</span>
</Link>
</div>
<div className="space-x-2">
<span>{beerPost.abv}% ABV</span>
<span>{beerPost.ibu} IBU</span>
</div>
</div>
);
})}
</div>
)}
{
/**
* If there are more beer posts to load, show a loading component with a
* skeleton loader and a loading spinner.
*/
!!isLoadingMore && !isAtEnd && (
<BeerRecommendationLoadingComponent length={PAGE_SIZE} />
)
}
</>
</div>
</div>
);
};
export default BeerStyleBeerSection;

View File

@@ -1,65 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { FunctionComponent } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod';
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
import toast from 'react-hot-toast';
import createErrorToast from '@/util/createErrorToast';
import BeerStyleQueryResult from '@/services/posts/beer-style-post/schema/BeerStyleQueryResult';
import useBeerStyleComments from '@/hooks/data-fetching/beer-style-comments/useBeerStyleComments';
import { sendCreateBeerStyleCommentRequest } from '@/requests/comments/beer-style-comment';
import CommentForm from '../Comments/CommentForm';
interface BeerCommentFormProps {
beerStyle: z.infer<typeof BeerStyleQueryResult>;
mutate: ReturnType<typeof useBeerStyleComments>['mutate'];
}
const BeerStyleCommentForm: FunctionComponent<BeerCommentFormProps> = ({
beerStyle,
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 sendCreateBeerStyleCommentRequest({
body: { content: data.content, rating: data.rating },
beerStyleId: beerStyle.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 BeerStyleCommentForm;

View File

@@ -1,86 +0,0 @@
import UserContext from '@/contexts/UserContext';
import { FC, MutableRefObject, useContext, useRef } from 'react';
import { z } from 'zod';
import { useRouter } from 'next/router';
import BeerStyleQueryResult from '@/services/posts/beer-style-post/schema/BeerStyleQueryResult';
import useBeerStyleComments from '@/hooks/data-fetching/beer-style-comments/useBeerStyleComments';
import {
sendDeleteBeerStyleCommentRequest,
sendEditBeerStyleCommentRequest,
} from '@/requests/comments/beer-style-comment';
import CommentLoadingComponent from '../Comments/CommentLoadingComponent';
import CommentsComponent from '../Comments/CommentsComponent';
import BeerStyleCommentForm from './BeerStyleCommentForm';
interface BeerStyleCommentsSectionProps {
beerStyle: z.infer<typeof BeerStyleQueryResult>;
}
const BeerStyleCommentsSection: FC<BeerStyleCommentsSectionProps> = ({ beerStyle }) => {
const { user } = useContext(UserContext);
const router = useRouter();
const pageNum = parseInt(router.query.comments_page as string, 10) || 1;
const PAGE_SIZE = 15;
const { comments, isLoading, mutate, setSize, size, isLoadingMore, isAtEnd } =
useBeerStyleComments({ id: beerStyle.id, pageNum, pageSize: PAGE_SIZE });
const commentSectionRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
return (
<div className="w-full space-y-3" ref={commentSectionRef}>
<div className="card bg-base-300">
<div className="card-body h-full">
{user ? (
<BeerStyleCommentForm beerStyle={beerStyle} mutate={mutate} />
) : (
<div className="flex h-52 flex-col items-center justify-center">
<span className="text-lg font-bold">Log in to leave a comment.</span>
</div>
)}
</div>
</div>
{
/**
* If the comments are loading, show a loading component. Otherwise, show the
* comments.
*/
isLoading ? (
<div className="card bg-base-300 pb-6">
<CommentLoadingComponent length={PAGE_SIZE} />
</div>
) : (
<CommentsComponent
commentSectionRef={commentSectionRef}
comments={comments}
isLoadingMore={isLoadingMore}
isAtEnd={isAtEnd}
pageSize={PAGE_SIZE}
setSize={setSize}
size={size}
mutate={mutate}
handleDeleteCommentRequest={(id) => {
return sendDeleteBeerStyleCommentRequest({
beerStyleId: beerStyle.id,
commentId: id,
});
}}
handleEditCommentRequest={(id, data) => {
return sendEditBeerStyleCommentRequest({
beerStyleId: beerStyle.id,
commentId: id,
body: data,
});
}}
/>
)
}
</div>
);
};
export default BeerStyleCommentsSection;

View File

@@ -1,112 +0,0 @@
import Link from 'next/link';
import format from 'date-fns/format';
import { FC, useContext } from 'react';
import UserContext from '@/contexts/UserContext';
import { FaRegEdit } from 'react-icons/fa';
import { z } from 'zod';
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import BeerStyleQueryResult from '@/services/posts/beer-style-post/schema/BeerStyleQueryResult';
import useBeerStyleLikeCount from '@/hooks/data-fetching/beer-style-likes/useBeerStyleLikeCount';
import BeerStyleLikeButton from './BeerStyleLikeButton';
interface BeerInfoHeaderProps {
beerStyle: z.infer<typeof BeerStyleQueryResult>;
}
const BeerStyleHeader: FC<BeerInfoHeaderProps> = ({ beerStyle }) => {
const createdAt = new Date(beerStyle.createdAt);
const timeDistance = useTimeDistance(createdAt);
const { user } = useContext(UserContext);
const idMatches = user && beerStyle.postedBy.id === user.id;
const isPostOwner = !!(user && idMatches);
const { likeCount, mutate } = useBeerStyleLikeCount(beerStyle.id);
return (
<article className="card flex flex-col justify-center bg-base-300">
<div className="card-body">
<header className="flex justify-between">
<div className="space-y-2">
<div>
<h1 className="text-2xl font-bold lg:text-4xl">{beerStyle.name}</h1>
</div>
<div>
<h3 className="italic">
{' posted by '}
<Link
href={`/users/${beerStyle.postedBy.id}`}
className="link-hover link"
>
{`${beerStyle.postedBy.username} `}
</Link>
{timeDistance && (
<span
className="tooltip tooltip-bottom"
data-tip={format(createdAt, 'MM/dd/yyyy')}
>
{`${timeDistance} ago`}
</span>
)}
</h3>
</div>
</div>
{isPostOwner && (
<div className="tooltip tooltip-left" data-tip={`Edit '${beerStyle.name}'`}>
<Link href={`/beers/${beerStyle.id}/edit`} className="btn btn-ghost btn-xs">
<FaRegEdit className="text-xl" />
</Link>
</div>
)}
</header>
<div>
<p>{beerStyle.description}</p>
</div>
<div className="flex justify-between">
<div className="space-y-2">
<div className="w-25 flex flex-row space-x-3">
<div className="text-sm font-bold">
ABV Range:{' '}
<span>
{beerStyle.abvRange[0].toFixed(1)}% - {beerStyle.abvRange[0].toFixed(1)}
%
</span>
</div>
<div className="text-sm font-bold">
IBU Range:{' '}
<span>
{beerStyle.ibuRange[0].toFixed(1)} - {beerStyle.ibuRange[1].toFixed(1)}
</span>
</div>
</div>
<div className="font-semibold">
Recommended Glassware:{' '}
<span className="text-sm font-bold italic">{beerStyle.glassware.name}</span>
</div>
<div className="flex justify-between">
<div>
{(!!likeCount || likeCount === 0) && (
<span>
Liked by {likeCount}
{likeCount !== 1 ? ' users' : ' user'}
</span>
)}
</div>
</div>
</div>
<div className="card-actions items-end">
{user && (
<BeerStyleLikeButton beerStyleId={beerStyle.id} mutateCount={mutate} />
)}
</div>
</div>
</div>
</article>
);
};
export default BeerStyleHeader;

View File

@@ -1,34 +0,0 @@
import { FC, useEffect, useState } from 'react';
import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount';
import useCheckIfUserLikesBeerStyle from '@/hooks/data-fetching/beer-style-likes/useCheckIfUserLikesBeerPost';
import sendBeerStyleLikeRequest from '@/requests/likes/beer-style-like/sendBeerStyleLikeRequest';
import LikeButton from '../ui/LikeButton';
const BeerStyleLikeButton: FC<{
beerStyleId: string;
mutateCount: ReturnType<typeof useGetBeerPostLikeCount>['mutate'];
}> = ({ beerStyleId, mutateCount }) => {
const { isLiked, mutate: mutateLikeStatus } = useCheckIfUserLikesBeerStyle(beerStyleId);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(false);
}, [isLiked]);
const handleLike = async () => {
try {
setLoading(true);
await sendBeerStyleLikeRequest(beerStyleId);
await Promise.all([mutateCount(), mutateLikeStatus()]);
setLoading(false);
} catch (e) {
setLoading(false);
}
};
return <LikeButton isLiked={!!isLiked} handleLike={handleLike} loading={loading} />;
};
export default BeerStyleLikeButton;

View File

@@ -1,48 +0,0 @@
import BeerStyleQueryResult from '@/services/posts/beer-style-post/schema/BeerStyleQueryResult';
import Link from 'next/link';
import { FC } from 'react';
import { z } from 'zod';
const BeerStyleCard: FC<{ beerStyle: z.infer<typeof BeerStyleQueryResult> }> = ({
beerStyle,
}) => {
return (
<div className="card card-compact bg-base-300">
<div className="card-body justify-between">
<div className="space-y-1">
<Link href={`/beers/styles/${beerStyle.id}`}>
<h3 className="link-hover link overflow-hidden whitespace-normal text-2xl font-bold lg:truncate lg:text-3xl">
{beerStyle.name}
</h3>
</Link>
<div className="w-25 flex flex-row space-x-3">
<div className="text-sm font-bold">
ABV Range:{' '}
<span>
{beerStyle.abvRange[0].toFixed(1)}% - {beerStyle.abvRange[0].toFixed(1)}%
</span>
</div>
<div className="text-sm font-bold">
IBU Range:{' '}
<span>
{beerStyle.ibuRange[0].toFixed(1)} - {beerStyle.ibuRange[1].toFixed(1)}
</span>
</div>
</div>
<div className="h-20">
<p className="line-clamp-3 overflow-ellipsis">{beerStyle.description}</p>
</div>
<div className="font-semibold">
Recommended Glassware:{' '}
<span className="text-sm font-bold italic">{beerStyle.glassware.name}</span>
</div>
</div>
</div>
</div>
);
};
export default BeerStyleCard;

View File

@@ -1,111 +0,0 @@
import UseBeerPostsByBrewery from '@/hooks/data-fetching/beer-posts/useBeerPostsByBrewery';
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
import Link from 'next/link';
import { FC, MutableRefObject, useContext, useRef } from 'react';
import { useInView } from 'react-intersection-observer';
import { z } from 'zod';
import { FaPlus } from 'react-icons/fa';
import UserContext from '@/contexts/UserContext';
import BeerRecommendationLoadingComponent from '../BeerById/BeerRecommendationLoadingComponent';
interface BreweryCommentsSectionProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>;
}
const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) => {
const PAGE_SIZE = 2;
const { user } = useContext(UserContext);
const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = UseBeerPostsByBrewery({
breweryId: breweryPost.id,
pageSize: PAGE_SIZE,
});
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;
setSize(size + 1);
},
});
const beerRecommendationsRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
return (
<div className="card h-full" ref={beerRecommendationsRef}>
<div className="card-body">
<>
<div className="my-2 flex flex-row items-center justify-between">
<div>
<h3 className="text-3xl font-bold">Brews</h3>
</div>
<div>
{user && (
<Link
className={`btn btn-ghost btn-sm gap-2 rounded-2xl outline`}
href={`/breweries/${breweryPost.id}/beers/create`}
>
<FaPlus className="text-xl" />
Add Beer
</Link>
)}
</div>
</div>
{!!beerPosts.length && (
<div className="space-y-5">
{beerPosts.map((beerPost, 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={beerPost.id}
>
<div>
<Link className="link-hover link" href={`/beers/${beerPost.id}`}>
<span className="text-xl font-semibold">{beerPost.name}</span>
</Link>
</div>
<div>
<Link
className="link-hover link text-lg font-medium"
href={`/beers/styles/${beerPost.style.id}`}
>
{beerPost.style.name}
</Link>
</div>
<div className="space-x-2">
<span>{beerPost.abv}% ABV</span>
<span>{beerPost.ibu} IBU</span>
</div>
</div>
);
})}
</div>
)}
{
/**
* If there are more beer posts to load, show a loading component with a
* skeleton loader and a loading spinner.
*/
!!isLoadingMore && !isAtEnd && (
<BeerRecommendationLoadingComponent length={PAGE_SIZE} />
)
}
</>
</div>
</div>
);
};
export default BreweryBeersSection;

View File

@@ -1,60 +0,0 @@
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
import CreateCommentValidationSchema from '@/services/schema/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/comments/brewery-comment/sendCreateBreweryCommentRequest';
import createErrorToast from '@/util/createErrorToast';
import CommentForm from '../Comments/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

@@ -1,88 +0,0 @@
import UserContext from '@/contexts/UserContext';
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
import { FC, MutableRefObject, useContext, useRef } from 'react';
import { z } from 'zod';
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import {
sendDeleteBreweryPostCommentRequest,
sendEditBreweryPostCommentRequest,
} from '@/requests/comments/brewery-comment';
import CommentLoadingComponent from '../Comments/CommentLoadingComponent';
import CommentsComponent from '../Comments/CommentsComponent';
import BreweryCommentForm from './BreweryCommentForm';
interface BreweryBeerSectionProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>;
}
const BreweryCommentsSection: FC<BreweryBeerSectionProps> = ({ breweryPost }) => {
const { user } = useContext(UserContext);
const PAGE_SIZE = 4;
const {
isLoading,
setSize,
size,
isLoadingMore,
isAtEnd,
mutate,
comments: breweryComments,
} = useBreweryPostComments({ id: breweryPost.id, pageSize: PAGE_SIZE });
const commentSectionRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
return (
<div className="w-full space-y-3" ref={commentSectionRef}>
<div className="card">
<div className="card-body h-full">
{user ? (
<BreweryCommentForm breweryPost={breweryPost} mutate={mutate} />
) : (
<div className="flex h-52 flex-col items-center justify-center">
<div className="text-lg font-bold">Log in to leave a comment.</div>
</div>
)}
</div>
</div>
{
/**
* If the comments are loading, show a loading component. Otherwise, show the
* comments.
*/
isLoading ? (
<div className="card pb-6">
<CommentLoadingComponent length={PAGE_SIZE} />
</div>
) : (
<CommentsComponent
comments={breweryComments}
isLoadingMore={isLoadingMore}
isAtEnd={isAtEnd}
pageSize={PAGE_SIZE}
setSize={setSize}
size={size}
commentSectionRef={commentSectionRef}
mutate={mutate}
handleDeleteCommentRequest={(id) => {
return sendDeleteBreweryPostCommentRequest({
breweryPostId: breweryPost.id,
commentId: id,
});
}}
handleEditCommentRequest={(commentId, data) => {
return sendEditBreweryPostCommentRequest({
breweryPostId: breweryPost.id,
commentId,
body: { content: data.content, rating: data.rating },
});
}}
/>
)
}
</div>
);
};
export default BreweryCommentsSection;

View File

@@ -1,100 +0,0 @@
import UserContext from '@/contexts/UserContext';
import useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount';
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
import { format } from 'date-fns';
import { FC, useContext } from 'react';
import { FaRegEdit } from 'react-icons/fa';
import { z } from 'zod';
import Link from 'next/link';
import BreweryPostLikeButton from '../BreweryIndex/BreweryPostLikeButton';
interface BreweryInfoHeaderProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>;
}
const BreweryInfoHeader: FC<BreweryInfoHeaderProps> = ({ breweryPost }) => {
const createdAt = new Date(breweryPost.createdAt);
const timeDistance = useTimeDistance(createdAt);
const { user } = useContext(UserContext);
const idMatches = user && breweryPost.postedBy.id === user.id;
const isPostOwner = !!(user && idMatches);
const { likeCount, mutate } = useGetBreweryPostLikeCount(breweryPost.id);
return (
<article className="card flex flex-col justify-center bg-base-300">
<div className="card-body">
<header className="flex justify-between">
<div className="w-full space-y-2">
<div className="flex w-full flex-row justify-between">
<div>
<h1 className="text-2xl font-bold lg:text-4xl">{breweryPost.name}</h1>
<h2 className="text-lg font-semibold lg:text-2xl">
Located in
{` ${breweryPost.location.city}, ${
breweryPost.location.stateOrProvince || breweryPost.location.country
}`}
</h2>
</div>
</div>
<div>
<h3 className="italic">
{' posted by '}
<Link
href={`/users/${breweryPost.postedBy.id}`}
className="link-hover link"
>
{`${breweryPost.postedBy.username} `}
</Link>
{timeDistance && (
<span
className="tooltip tooltip-bottom"
data-tip={format(createdAt, 'MM/dd/yyyy')}
>{`${timeDistance} ago`}</span>
)}
</h3>
</div>
</div>
{isPostOwner && (
<div className="tooltip tooltip-left" data-tip={`Edit '${breweryPost.name}'`}>
<Link
href={`/breweries/${breweryPost.id}/edit`}
className="btn btn-ghost btn-xs"
>
<FaRegEdit className="text-xl" />
</Link>
</div>
)}
</header>
<div className="space-y-2">
<p>{breweryPost.description}</p>
<div className="flex items-end justify-between">
<div className="space-y-1">
<div>
{(!!likeCount || likeCount === 0) && (
<span>
Liked by {likeCount} {likeCount === 1 ? 'user' : 'users'}
</span>
)}
</div>
</div>
<div className="card-actions">
{user && (
<BreweryPostLikeButton
breweryPostId={breweryPost.id}
mutateCount={mutate}
/>
)}
</div>
</div>
</div>
</div>
</article>
);
};
export default BreweryInfoHeader;

View File

@@ -1,60 +0,0 @@
import useMediaQuery from '@/hooks/utilities/useMediaQuery';
import 'mapbox-gl/dist/mapbox-gl.css';
import { FC, useMemo } from 'react';
import Map, { Marker } from 'react-map-gl';
import LocationMarker from '../ui/LocationMarker';
import ControlPanel from '../ui/maps/ControlPanel';
interface BreweryMapProps {
coordinates: { latitude: number; longitude: number };
token: string;
}
type MapStyles = Record<'light' | 'dark', `mapbox://styles/mapbox/${string}`>;
const BreweryPostMap: FC<BreweryMapProps> = ({
coordinates: { latitude, longitude },
token,
}) => {
const isDesktop = useMediaQuery('(min-width: 1024px)');
const windowIsDefined = typeof window !== 'undefined';
const themeIsDefined = windowIsDefined && !!window.localStorage.getItem('theme');
const theme = (
windowIsDefined && themeIsDefined ? window.localStorage.getItem('theme') : 'light'
) as 'light' | 'dark';
const pin = useMemo(
() => (
<Marker latitude={latitude} longitude={longitude}>
<LocationMarker />
</Marker>
),
[latitude, longitude],
);
const mapStyles: MapStyles = {
light: 'mapbox://styles/mapbox/light-v10',
dark: 'mapbox://styles/mapbox/dark-v11',
};
return (
<div className="card">
<div className="card-body">
<Map
initialViewState={{ latitude, longitude, zoom: 17 }}
style={{ width: '100%', height: isDesktop ? 480 : 240 }}
mapStyle={mapStyles[theme]}
mapboxAccessToken={token}
scrollZoom
>
<ControlPanel />
{pin}
</Map>
</div>
</div>
);
};
export default BreweryPostMap;

View File

@@ -1,64 +0,0 @@
import UserContext from '@/contexts/UserContext';
import useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount';
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
import { FC, useContext } from 'react';
import Link from 'next/link';
import { z } from 'zod';
import { CldImage } from 'next-cloudinary';
import BreweryPostLikeButton from './BreweryPostLikeButton';
const BreweryCard: FC<{ brewery: z.infer<typeof BreweryPostQueryResult> }> = ({
brewery,
}) => {
const { user } = useContext(UserContext);
const { likeCount, mutate, isLoading } = useGetBreweryPostLikeCount(brewery.id);
return (
<div className="card" key={brewery.id}>
<figure className="card-image h-96">
<Link href={`/breweries/${brewery.id}`} className="h-full object-cover">
{brewery.breweryImages.length > 0 && (
<CldImage
src={brewery.breweryImages[0].path}
alt={brewery.name}
width="1029"
height="1029"
crop="fill"
className="h-full object-cover"
/>
)}
</Link>
</figure>
<div className="card-body justify-between">
<div>
<Link href={`/breweries/${brewery.id}`} className="link-hover link">
<span className="text-lg font-bold lg:text-xl xl:truncate">
{brewery.name}
</span>
</Link>
</div>
<div className="flex w-full items-end justify-between">
<div className="w-9/12">
<h3 className="text-lg font-semibold lg:text-xl xl:truncate">
{brewery.location.city},{' '}
{brewery.location.stateOrProvince || brewery.location.country}
</h3>
<h4 className="text-lg font-semibold lg:text-xl">
est. {brewery.dateEstablished.getFullYear()}
</h4>
<div className="mt-2">
{!isLoading && <span>liked by {likeCount} users</span>}
</div>
</div>
<div>
{!!user && !isLoading && (
<BreweryPostLikeButton breweryPostId={brewery.id} mutateCount={mutate} />
)}
</div>
</div>
</div>
</div>
);
};
export default BreweryCard;

View File

@@ -1,30 +0,0 @@
import useCheckIfUserLikesBreweryPost from '@/hooks/data-fetching/brewery-likes/useCheckIfUserLikesBreweryPost';
import useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount';
import sendBreweryPostLikeRequest from '@/requests/likes/brewery-post-like/sendBreweryPostLikeRequest';
import { FC, useState } from 'react';
import LikeButton from '../ui/LikeButton';
const BreweryPostLikeButton: FC<{
breweryPostId: string;
mutateCount: ReturnType<typeof useGetBreweryPostLikeCount>['mutate'];
}> = ({ breweryPostId, mutateCount }) => {
const { isLiked, mutate: mutateLikeStatus } =
useCheckIfUserLikesBreweryPost(breweryPostId);
const [isLoading, setIsLoading] = useState(false);
const handleLike = async () => {
try {
setIsLoading(true);
await sendBreweryPostLikeRequest(breweryPostId);
await Promise.all([mutateCount(), mutateLikeStatus()]);
setIsLoading(false);
} catch (e) {
setIsLoading(false);
}
};
return <LikeButton isLiked={!!isLiked} handleLike={handleLike} loading={isLoading} />;
};
export default BreweryPostLikeButton;

View File

@@ -1,285 +0,0 @@
import sendUploadBreweryImagesRequest from '@/requests/images/brewery-image/sendUploadBreweryImageRequest';
import CreateBreweryPostSchema from '@/services/posts/brewery-post/schema/CreateBreweryPostSchema';
import UploadImageValidationSchema from '@/services/schema/ImageSchema/UploadImageValidationSchema';
import createErrorToast from '@/util/createErrorToast';
import { Tab } from '@headlessui/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AddressAutofillRetrieveResponse } from '@mapbox/search-js-core';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { FC, Fragment } from 'react';
import {
useForm,
SubmitHandler,
FieldError,
UseFormRegister,
FieldErrors,
UseFormSetValue,
} from 'react-hook-form';
import toast from 'react-hot-toast';
import { z } from 'zod';
import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo';
import FormLabel from '../ui/forms/FormLabel';
import FormSegment from '../ui/forms/FormSegment';
import FormTextArea from '../ui/forms/FormTextArea';
import FormTextInput from '../ui/forms/FormTextInput';
import Button from '../ui/forms/Button';
import { sendCreateBreweryPostRequest } from '@/requests/posts/brewery-post';
const AddressAutofill = dynamic(
// @ts-expect-error
() => import('@mapbox/search-js-react').then((mod) => mod.AddressAutofill),
{ ssr: false },
);
const CreateBreweryPostWithImagesSchema = CreateBreweryPostSchema.merge(
UploadImageValidationSchema,
);
const InfoSection: FC<{
register: UseFormRegister<z.infer<typeof CreateBreweryPostWithImagesSchema>>;
errors: FieldErrors<z.infer<typeof CreateBreweryPostWithImagesSchema>>;
isSubmitting: boolean;
}> = ({ register, errors, isSubmitting }) => {
return (
<>
<FormInfo>
<FormLabel htmlFor="name">Name</FormLabel>
<FormError>{errors.name?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Lorem Ipsum Brewing Company"
formValidationSchema={register('name')}
error={!!errors.name}
type="text"
id="name"
disabled={isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="description">Description</FormLabel>
<FormError>{errors.description?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
placeholder="We make beer, and we make it good."
formValidationSchema={register('description')}
error={!!errors.description}
rows={4}
id="description"
disabled={isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="dateEstablished">Date Established</FormLabel>
<FormError>{errors.dateEstablished?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="2021-01-01"
formValidationSchema={register('dateEstablished')}
error={!!errors.dateEstablished}
type="date"
id="dateEstablished"
disabled={isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="images">Images</FormLabel>
<FormError>{(errors.images as FieldError | undefined)?.message}</FormError>
</FormInfo>
<FormSegment>
<input
type="file"
{...register('images')}
multiple
className="file-input file-input-bordered w-full"
disabled={isSubmitting}
/>
</FormSegment>
</>
);
};
const LocationSection: FC<{
register: UseFormRegister<z.infer<typeof CreateBreweryPostWithImagesSchema>>;
errors: FieldErrors<z.infer<typeof CreateBreweryPostWithImagesSchema>>;
isSubmitting: boolean;
setValue: UseFormSetValue<z.infer<typeof CreateBreweryPostWithImagesSchema>>;
mapboxAccessToken: string;
}> = ({ register, errors, isSubmitting, setValue, mapboxAccessToken }) => {
const onAutoCompleteChange = (address: string) => {
setValue('address', address);
};
const onAutoCompleteRetrieve = (address: AddressAutofillRetrieveResponse) => {
const { country, region, place } = address.features[0].properties as unknown as {
country?: string;
region?: string;
place?: string;
};
setValue('country', country);
setValue('region', region);
setValue('city', place!);
};
return (
<>
<FormInfo>
<FormLabel htmlFor="address">Address</FormLabel>
<FormError>{errors.address?.message}</FormError>
</FormInfo>
<FormSegment>
<AddressAutofill
accessToken={mapboxAccessToken}
onRetrieve={onAutoCompleteRetrieve}
onChange={onAutoCompleteChange}
>
<input
id="address"
type="text"
placeholder="1234 Main St"
className={`input input-bordered w-full appearance-none rounded-lg transition ease-in-out ${
errors.address?.message ? 'input-error' : ''
}`}
{...register('address')}
disabled={isSubmitting}
/>
</AddressAutofill>
</FormSegment>
<div className="flex space-x-3">
<div className="w-1/2">
<FormInfo>
<FormLabel htmlFor="city">City</FormLabel>
<FormError>{errors.city?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Toronto"
formValidationSchema={register('city')}
error={!!errors.city}
type="text"
id="city"
disabled={isSubmitting}
/>
</FormSegment>
</div>
<div className="w-1/2">
<FormInfo>
<FormLabel htmlFor="region">Region</FormLabel>
<FormError>{errors.region?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Ontario"
formValidationSchema={register('region')}
error={!!errors.region}
type="text"
id="region"
disabled={isSubmitting}
/>
</FormSegment>
</div>
</div>
<FormInfo>
<FormLabel htmlFor="country">Country</FormLabel>
<FormError>{errors.country?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Canada"
formValidationSchema={register('country')}
error={!!errors.country}
type="text"
id="country"
disabled={isSubmitting}
/>
</FormSegment>
</>
);
};
const CreateBreweryPostForm: FC<{
mapboxAccessToken: string;
}> = ({ mapboxAccessToken }) => {
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors, isSubmitting },
} = useForm<z.infer<typeof CreateBreweryPostWithImagesSchema>>({
resolver: zodResolver(CreateBreweryPostWithImagesSchema),
});
const router = useRouter();
const onSubmit: SubmitHandler<
z.infer<typeof CreateBreweryPostWithImagesSchema>
> = async (data) => {
const loadingToast = toast.loading('Creating brewery...');
try {
if (!(data.images instanceof FileList)) {
return;
}
const breweryPost = await sendCreateBreweryPostRequest({ body: data });
await sendUploadBreweryImagesRequest({ breweryPost, images: data.images });
await router.push(`/breweries/${breweryPost.id}`);
toast.remove(loadingToast);
toast.success('Created brewery.');
} catch (error) {
toast.remove(loadingToast);
createErrorToast(error);
reset();
}
};
return (
<form
onSubmit={handleSubmit(onSubmit, (error) => {
const fieldErrors = Object.keys(error).length;
toast.error(`Form submission failed.`);
toast.error(`You have ${fieldErrors} errors in your form.`);
})}
className="form-control"
autoComplete="off"
>
<Tab.Group as={Fragment}>
<Tab.List className="tabs-boxed tabs grid grid-cols-2">
<Tab className="tab uppercase ui-selected:tab-active">Information</Tab>
<Tab className="tab uppercase ui-selected:tab-active">Location</Tab>
</Tab.List>
<Tab.Panels className="mt-4">
<Tab.Panel>
<InfoSection
register={register}
errors={errors}
isSubmitting={isSubmitting}
/>
</Tab.Panel>
<Tab.Panel>
<LocationSection
setValue={setValue}
register={register}
errors={errors}
isSubmitting={isSubmitting}
mapboxAccessToken={mapboxAccessToken}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
<div className="mt-8">
<Button type="submit" isSubmitting={isSubmitting}>
Create Brewery Post
</Button>
</div>
</form>
);
};
export default CreateBreweryPostForm;

View File

@@ -1,54 +0,0 @@
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
import { FC, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { z } from 'zod';
import CommentContentBody from './CommentContentBody';
import EditCommentBody from './EditCommentBody';
import UserAvatar from '../Account/UserAvatar';
import { HandleDeleteCommentRequest, HandleEditCommentRequest } from './types';
interface CommentCardProps {
comment: z.infer<typeof CommentQueryResult>;
mutate: ReturnType<typeof useBeerPostComments>['mutate'];
ref?: ReturnType<typeof useInView>['ref'];
handleDeleteCommentRequest: HandleDeleteCommentRequest;
handleEditCommentRequest: HandleEditCommentRequest;
}
const CommentCardBody: FC<CommentCardProps> = ({
comment,
mutate,
ref,
handleDeleteCommentRequest,
handleEditCommentRequest,
}) => {
const [inEditMode, setInEditMode] = useState(false);
return (
<div ref={ref} className="flex items-start">
<div className="mx-3 w-[20%] justify-center sm:w-[12%]">
<div className="h-20 pt-4">
<UserAvatar user={comment.postedBy} />
</div>
</div>
<div className="h-full w-[88%]">
{!inEditMode ? (
<CommentContentBody comment={comment} setInEditMode={setInEditMode} />
) : (
<EditCommentBody
comment={comment}
mutate={mutate}
setInEditMode={setInEditMode}
handleDeleteCommentRequest={handleDeleteCommentRequest}
handleEditCommentRequest={handleEditCommentRequest}
/>
)}
</div>
</div>
);
};
export default CommentCardBody;

View File

@@ -1,55 +0,0 @@
import UserContext from '@/contexts/UserContext';
import { Dispatch, SetStateAction, FC, useContext } from 'react';
import { FaEllipsisH } from 'react-icons/fa';
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
import { z } from 'zod';
interface CommentCardDropdownProps {
comment: z.infer<typeof CommentQueryResult>;
setInEditMode: Dispatch<SetStateAction<boolean>>;
}
const CommentCardDropdown: FC<CommentCardDropdownProps> = ({
comment,
setInEditMode,
}) => {
const { user } = useContext(UserContext);
const isCommentOwner = user?.id === comment.postedBy.id;
return (
<div className="dropdown dropdown-end">
<label tabIndex={0} className="btn btn-ghost btn-sm m-1">
<FaEllipsisH />
</label>
<ul
tabIndex={0}
className="menu dropdown-content rounded-box w-52 bg-base-100 p-2 shadow"
>
<li>
{isCommentOwner ? (
<button
type="button"
onClick={() => {
setInEditMode(true);
}}
>
Edit
</button>
) : (
<button
type="button"
onClick={() => {
// eslint-disable-next-line no-alert
alert('This feature is not yet implemented.');
}}
>
Report
</button>
)}
</li>
</ul>
</div>
);
};
export default CommentCardDropdown;

View File

@@ -1,77 +0,0 @@
import UserContext from '@/contexts/UserContext';
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import { format } from 'date-fns';
import { Dispatch, FC, SetStateAction, useContext } from 'react';
import { Rating } from 'react-daisyui';
import Link from 'next/link';
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
import { z } from 'zod';
import { useInView } from 'react-intersection-observer';
import classNames from 'classnames';
import CommentCardDropdown from './CommentCardDropdown';
interface CommentContentBodyProps {
comment: z.infer<typeof CommentQueryResult>;
setInEditMode: Dispatch<SetStateAction<boolean>>;
}
const CommentContentBody: FC<CommentContentBodyProps> = ({ comment, setInEditMode }) => {
const { user } = useContext(UserContext);
const timeDistance = useTimeDistance(new Date(comment.createdAt));
const [ref, inView] = useInView({ triggerOnce: true });
return (
<div
className={classNames('space-y-1 py-4 pr-3 fade-in-10', {
'opacity-0': !inView,
'animate-fade': inView,
})}
ref={ref}
>
<div className="space-y-2">
<div className="flex flex-row justify-between">
<div>
<p className="font-semibold sm:text-2xl">
<Link href={`/users/${comment.postedBy.id}`} className="link-hover link">
{comment.postedBy.username}
</Link>
</p>
<span className="italic">
posted{' '}
<time
className="tooltip tooltip-bottom"
data-tip={format(new Date(comment.createdAt), 'MM/dd/yyyy')}
>
{timeDistance}
</time>{' '}
ago
</span>
</div>
{user && (
<CommentCardDropdown comment={comment} setInEditMode={setInEditMode} />
)}
</div>
<div className="space-y-1">
<Rating value={comment.rating}>
{Array.from({ length: 5 }).map((val, index) => (
<Rating.Item
name="rating-1"
className="mask mask-star cursor-default"
disabled
aria-disabled
key={index}
/>
))}
</Rating>
</div>
</div>
<div>
<p className="text-sm">{comment.content}</p>
</div>
</div>
);
};
export default CommentContentBody;

View File

@@ -1,85 +0,0 @@
import { FC } from 'react';
import { Rating } from 'react-daisyui';
import type {
FormState,
SubmitHandler,
UseFormHandleSubmit,
UseFormRegister,
UseFormSetValue,
UseFormWatch,
} from 'react-hook-form';
import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo';
import FormLabel from '../ui/forms/FormLabel';
import FormSegment from '../ui/forms/FormSegment';
import FormTextArea from '../ui/forms/FormTextArea';
import Button from '../ui/forms/Button';
interface Comment {
content: string;
rating: number;
}
interface CommentFormProps {
handleSubmit: UseFormHandleSubmit<Comment>;
onSubmit: SubmitHandler<Comment>;
watch: UseFormWatch<Comment>;
setValue: UseFormSetValue<Comment>;
formState: FormState<Comment>;
register: UseFormRegister<Comment>;
}
const CommentForm: FC<CommentFormProps> = ({
handleSubmit,
onSubmit,
watch,
setValue,
formState,
register,
}) => {
const { errors } = formState;
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div>
<FormInfo>
<FormLabel htmlFor="content">Leave a comment</FormLabel>
<FormError>{errors.content?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
id="content"
formValidationSchema={register('content')}
placeholder="Comment"
rows={5}
error={!!errors.content?.message}
disabled={formState.isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="rating">Rating</FormLabel>
<FormError>{errors.rating?.message}</FormError>
</FormInfo>
<Rating
value={watch('rating')}
onChange={(value) => {
setValue('rating', value);
}}
>
<Rating.Item name="rating-1" className="mask mask-star" />
<Rating.Item name="rating-1" className="mask mask-star" />
<Rating.Item name="rating-1" className="mask mask-star" />
<Rating.Item name="rating-1" className="mask mask-star" />
<Rating.Item name="rating-1" className="mask mask-star" />
</Rating>
</div>
<div>
<Button type="submit" isSubmitting={formState.isSubmitting}>
Submit
</Button>
</div>
</form>
);
};
export default CommentForm;

View File

@@ -1,19 +0,0 @@
const CommentLoadingCardBody = () => {
return (
<div className="card-body h-52 fade-in-10">
<div className="flex animate-pulse space-x-4 slide-in-from-top">
<div className="flex-1 space-y-4 py-1">
<div className="h-4 w-3/4 rounded bg-base-100" />
<div className="space-y-2">
<div className="h-4 rounded bg-base-100" />
<div className="h-4 w-11/12 rounded bg-base-100" />
<div className="h-4 w-10/12 rounded bg-base-100" />
<div className="h-4 w-11/12 rounded bg-base-100" />
</div>
</div>
</div>
</div>
);
};
export default CommentLoadingCardBody;

View File

@@ -1,22 +0,0 @@
import { FC } from 'react';
import Spinner from '../ui/Spinner';
import CommentLoadingCardBody from './CommentLoadingCardBody';
interface CommentLoadingComponentProps {
length: number;
}
const CommentLoadingComponent: FC<CommentLoadingComponentProps> = ({ length }) => {
return (
<>
{Array.from({ length }).map((_, i) => (
<CommentLoadingCardBody key={i} />
))}
<div className="p-1">
<Spinner size="sm" />
</div>
</>
);
};
export default CommentLoadingComponent;

View File

@@ -1,125 +0,0 @@
import { FC, MutableRefObject } from 'react';
import { FaArrowUp } from 'react-icons/fa';
import { useInView } from 'react-intersection-observer';
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import useBeerStyleComments from '@/hooks/data-fetching/beer-style-comments/useBeerStyleComments';
import NoCommentsCard from './NoCommentsCard';
import CommentLoadingComponent from './CommentLoadingComponent';
import CommentCardBody from './CommentCardBody';
import { HandleDeleteCommentRequest, HandleEditCommentRequest } from './types';
type HookReturnType = ReturnType<
typeof useBeerPostComments | typeof useBreweryPostComments | typeof useBeerStyleComments
>;
interface CommentsComponentProps {
comments: HookReturnType['comments'];
isAtEnd: HookReturnType['isAtEnd'];
isLoadingMore: HookReturnType['isLoadingMore'];
mutate: HookReturnType['mutate'];
setSize: HookReturnType['setSize'];
size: HookReturnType['size'];
commentSectionRef: MutableRefObject<HTMLDivElement | null>;
handleDeleteCommentRequest: HandleDeleteCommentRequest;
handleEditCommentRequest: HandleEditCommentRequest;
pageSize: number;
}
const CommentsComponent: FC<CommentsComponentProps> = ({
comments,
commentSectionRef,
handleDeleteCommentRequest,
handleEditCommentRequest,
isAtEnd,
isLoadingMore,
mutate,
pageSize,
setSize,
size,
}) => {
const { ref: penultimateCommentRef } = useInView({
threshold: 0.1,
/**
* When the last comment comes into view, call setSize from the comment fetching hook
* to load more comments.
*/
onChange: (visible) => {
if (!visible || isAtEnd) return;
setSize(size + 1);
},
});
return (
<>
{!!comments.length && (
<div className="card h-full bg-base-300 pb-6">
{comments.map((comment, index) => {
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.
*/
return (
<div
ref={isLastComment ? penultimateCommentRef : undefined}
key={comment.id}
>
<CommentCardBody
comment={comment}
mutate={mutate}
handleDeleteCommentRequest={handleDeleteCommentRequest}
handleEditCommentRequest={handleEditCommentRequest}
/>
</div>
);
})}
{
/**
* If there are more comments to load, show a loading component with a
* skeleton loader and a loading spinner.
*/
!!isLoadingMore && <CommentLoadingComponent 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.
*/
!!isAtEnd && (
<div className="flex h-20 items-center justify-center text-center">
<div
className="tooltip tooltip-bottom"
data-tip="Scroll back to top of comments."
>
<button
type="button"
className="btn btn-ghost btn-sm"
aria-label="Scroll back to top of comments"
onClick={() => {
commentSectionRef.current?.scrollIntoView({
behavior: 'smooth',
});
}}
>
<FaArrowUp />
</button>
</div>
</div>
)
}
</div>
)}
{!comments.length && <NoCommentsCard />}
</>
);
};
export default CommentsComponent;

View File

@@ -1,168 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { FC, useState, Dispatch, SetStateAction } from 'react';
import { Rating } from 'react-daisyui';
import { useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod';
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
import CreateCommentValidationSchema from '@/services/schema/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';
import FormSegment from '../ui/forms/FormSegment';
import FormTextArea from '../ui/forms/FormTextArea';
import { HandleDeleteCommentRequest, HandleEditCommentRequest } from './types';
import { useInView } from 'react-intersection-observer';
import classNames from 'classnames';
interface EditCommentBodyProps {
comment: z.infer<typeof CommentQueryResult>;
setInEditMode: Dispatch<SetStateAction<boolean>>;
mutate: ReturnType<
typeof useBeerPostComments | typeof useBreweryPostComments
>['mutate'];
handleDeleteCommentRequest: HandleDeleteCommentRequest;
handleEditCommentRequest: HandleEditCommentRequest;
}
const EditCommentBody: FC<EditCommentBodyProps> = ({
comment,
setInEditMode,
mutate,
handleDeleteCommentRequest,
handleEditCommentRequest,
}) => {
const { register, handleSubmit, formState, setValue, watch } = useForm<
z.infer<typeof CreateCommentValidationSchema>
>({
defaultValues: { content: comment.content, rating: comment.rating },
resolver: zodResolver(CreateCommentValidationSchema),
});
const { errors, isSubmitting } = formState;
const [isDeleting, setIsDeleting] = useState(false);
const onDelete = async () => {
const loadingToast = toast.loading('Deleting comment...');
setIsDeleting(true);
try {
await handleDeleteCommentRequest(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 handleEditCommentRequest(comment.id, data);
await mutate();
toast.remove(loadingToast);
toast.success('Comment edits submitted successfully.');
setInEditMode(false);
} catch (error) {
toast.remove(loadingToast);
createErrorToast(error);
setInEditMode(false);
}
};
const disableForm = isSubmitting || isDeleting;
const [ref, inView] = useInView({ triggerOnce: true });
return (
<div
className={classNames('py-4 pr-3 animate-in fade-in-10', {
'opacity-0': !inView,
'animate-fade': inView,
})}
ref={ref}
>
<form onSubmit={handleSubmit(onEdit)} className="space-y-3">
<div>
<FormInfo>
<FormLabel htmlFor="content">Edit your comment</FormLabel>
<FormError>{errors.content?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
id="content"
formValidationSchema={register('content')}
placeholder="Comment"
rows={2}
error={!!errors.content?.message}
disabled={disableForm}
/>
</FormSegment>
<div className="flex flex-row items-center justify-between">
<div>
<FormInfo>
<FormLabel htmlFor="rating">Change your rating</FormLabel>
<FormError>{errors.rating?.message}</FormError>
</FormInfo>
<Rating
value={watch('rating')}
onChange={(value) => {
setValue('rating', value);
}}
>
{Array.from({ length: 5 }).map((val, index) => (
<Rating.Item
name="rating-1"
className="mask mask-star cursor-default"
disabled={disableForm}
aria-disabled={disableForm}
key={index}
/>
))}
</Rating>
</div>
<div className="join">
<button
type="button"
className="btn join-item btn-xs lg:btn-sm"
disabled={disableForm}
onClick={() => {
setInEditMode(false);
}}
>
Cancel
</button>
<button
type="submit"
disabled={disableForm}
className="btn join-item btn-xs lg:btn-sm"
>
Save
</button>
<button
type="button"
className="btn join-item btn-xs lg:btn-sm"
onClick={onDelete}
disabled={disableForm}
>
Delete
</button>
</div>
</div>
</div>
</form>
</div>
);
};
export default EditCommentBody;

View File

@@ -1,13 +0,0 @@
const NoCommentsCard = () => {
return (
<div className="card bg-base-300">
<div className="card-body h-64">
<div className="flex h-full flex-col items-center justify-center">
<span className="text-lg font-bold">No comments yet.</span>
</div>
</div>
</div>
);
};
export default NoCommentsCard;

View File

@@ -1,12 +0,0 @@
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod';
type APIResponse = z.infer<typeof APIResponseValidationSchema>;
export type HandleEditCommentRequest = (
id: string,
data: z.infer<typeof CreateCommentValidationSchema>,
) => Promise<APIResponse>;
export type HandleDeleteCommentRequest = (id: string) => Promise<APIResponse>;

View File

@@ -1,182 +0,0 @@
import { FunctionComponent } from 'react';
import router from 'next/router';
import { zodResolver } from '@hookform/resolvers/zod';
import { BeerStyle } from '@prisma/client';
import toast from 'react-hot-toast';
import { useForm, SubmitHandler, FieldError } from 'react-hook-form';
import { z } from 'zod';
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
import CreateBeerPostValidationSchema from '@/services/posts/beer-post/schema/CreateBeerPostValidationSchema';
import UploadImageValidationSchema from '@/services/schema/ImageSchema/UploadImageValidationSchema';
import createErrorToast from '@/util/createErrorToast';
import { sendCreateBeerPostRequest } from '@/requests/posts/beer-post';
import sendUploadBeerImagesRequest from '@/requests/images/beer-image/sendUploadBeerImageRequest';
import Button from './ui/forms/Button';
import FormError from './ui/forms/FormError';
import FormInfo from './ui/forms/FormInfo';
import FormLabel from './ui/forms/FormLabel';
import FormSegment from './ui/forms/FormSegment';
import FormSelect from './ui/forms/FormSelect';
import FormTextArea from './ui/forms/FormTextArea';
import FormTextInput from './ui/forms/FormTextInput';
interface BeerFormProps {
brewery: z.infer<typeof BreweryPostQueryResult>;
styles: BeerStyle[];
}
const CreateBeerPostWithImagesValidationSchema = CreateBeerPostValidationSchema.merge(
UploadImageValidationSchema,
);
const CreateBeerPostForm: FunctionComponent<BeerFormProps> = ({
styles = [],
brewery,
}) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<z.infer<typeof CreateBeerPostWithImagesValidationSchema>>({
resolver: zodResolver(CreateBeerPostWithImagesValidationSchema),
defaultValues: { breweryId: brewery.id },
});
const onSubmit: SubmitHandler<
z.infer<typeof CreateBeerPostWithImagesValidationSchema>
> = async (data) => {
if (!(data.images instanceof FileList)) {
return;
}
try {
const loadingToast = toast.loading('Creating beer post...');
const beerPost = await sendCreateBeerPostRequest({
body: {
name: data.name,
description: data.description,
abv: data.abv,
ibu: data.ibu,
},
breweryId: data.breweryId,
styleId: data.styleId,
});
await sendUploadBeerImagesRequest({ beerPost, images: data.images });
await router.push(`/beers/${beerPost.id}`);
toast.dismiss(loadingToast);
toast.success('Created beer post.');
} catch (e) {
createErrorToast(e);
}
};
return (
<form className="form-control" onSubmit={handleSubmit(onSubmit)}>
<FormInfo>
<FormLabel htmlFor="name">Name</FormLabel>
<FormError>{errors.name?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Lorem Ipsum Lager"
formValidationSchema={register('name')}
error={!!errors.name}
type="text"
id="name"
disabled={isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="typeId">Style</FormLabel>
<FormError>{errors.styleId?.message}</FormError>
</FormInfo>
<FormSegment>
<FormSelect
disabled={isSubmitting}
formRegister={register('styleId')}
error={!!errors.styleId}
id="styleId"
options={styles.map((style) => ({
value: style.id,
text: style.name,
}))}
placeholder="Beer style"
message="Pick a beer style"
/>
</FormSegment>
<div className="flex flex-wrap md:mb-3">
<div className="mb-2 w-full md:mb-0 md:w-1/2 md:pr-3">
<FormInfo>
<FormLabel htmlFor="abv">ABV</FormLabel>
<FormError>{errors.abv?.message}</FormError>
</FormInfo>
<FormTextInput
disabled={isSubmitting}
placeholder="12"
formValidationSchema={register('abv', { valueAsNumber: true })}
error={!!errors.abv}
type="text"
id="abv"
/>
</div>
<div className="mb-2 w-full md:mb-0 md:w-1/2 md:pl-3">
<FormInfo>
<FormLabel htmlFor="ibu">IBU</FormLabel>
<FormError>{errors.ibu?.message}</FormError>
</FormInfo>
<FormTextInput
disabled={isSubmitting}
placeholder="52"
formValidationSchema={register('ibu', { valueAsNumber: true })}
error={!!errors.ibu}
type="text"
id="lastName"
/>
</div>
</div>
<FormInfo>
<FormLabel htmlFor="description">Description</FormLabel>
<FormError>{errors.description?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
disabled={isSubmitting}
placeholder="Ratione cumque quas quia aut impedit ea culpa facere. Ut in sit et quas reiciendis itaque."
error={!!errors.description}
formValidationSchema={register('description')}
id="description"
rows={8}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="images">Images</FormLabel>
<FormError>{(errors.images as FieldError | undefined)?.message}</FormError>
</FormInfo>
<FormSegment>
<input
type="file"
{...register('images')}
multiple
className="file-input file-input-bordered w-full"
disabled={isSubmitting}
/>
</FormSegment>
<div className="mt-6">
<Button type="submit" isSubmitting={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
</div>
</form>
);
};
export default CreateBeerPostForm;

View File

@@ -1,150 +0,0 @@
import { FC } from 'react';
import { useRouter } from 'next/router';
import toast from 'react-hot-toast';
import { z } from 'zod';
import { useForm, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import EditBeerPostValidationSchema from '@/services/posts/beer-post/schema/EditBeerPostValidationSchema';
import createErrorToast from '@/util/createErrorToast';
import {
sendEditBeerPostRequest,
sendDeleteBeerPostRequest,
} from '@/requests/posts/beer-post';
import Button from './ui/forms/Button';
import FormError from './ui/forms/FormError';
import FormInfo from './ui/forms/FormInfo';
import FormLabel from './ui/forms/FormLabel';
import FormSegment from './ui/forms/FormSegment';
import FormTextArea from './ui/forms/FormTextArea';
import FormTextInput from './ui/forms/FormTextInput';
type EditBeerPostSchema = z.infer<typeof EditBeerPostValidationSchema>;
interface EditBeerPostFormProps {
previousValues: EditBeerPostSchema;
}
const EditBeerPostForm: FC<EditBeerPostFormProps> = ({ previousValues }) => {
const router = useRouter();
const { register, handleSubmit, formState } = useForm<EditBeerPostSchema>({
resolver: zodResolver(EditBeerPostValidationSchema),
defaultValues: previousValues,
});
const { isSubmitting, errors } = formState;
const onSubmit: SubmitHandler<EditBeerPostSchema> = async (data) => {
try {
const loadingToast = toast.loading('Editing beer post...');
await sendEditBeerPostRequest({
beerPostId: data.id,
body: {
name: data.name,
abv: data.abv,
ibu: data.ibu,
description: data.description,
},
});
await router.push(`/beers/${data.id}`);
toast.success('Edited beer post.');
toast.dismiss(loadingToast);
} catch (e) {
createErrorToast(e);
await router.push(`/beers/${data.id}`);
}
};
const onDelete = async () => {
try {
const loadingToast = toast.loading('Deleting beer post...');
await sendDeleteBeerPostRequest({ beerPostId: previousValues.id });
toast.dismiss(loadingToast);
await router.push('/beers');
toast.success('Deleted beer post.');
} catch (e) {
createErrorToast(e);
await router.push(`/beers`);
}
};
return (
<form className="form-control" onSubmit={handleSubmit(onSubmit)}>
<FormInfo>
<FormLabel htmlFor="name">Name</FormLabel>
<FormError>{errors.name?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Lorem Ipsum Lager"
formValidationSchema={register('name')}
error={!!errors.name}
type="text"
id="name"
disabled={isSubmitting}
/>
</FormSegment>
<div className="flex flex-wrap sm:text-xs md:mb-3">
<div className="mb-2 w-full md:mb-0 md:w-1/2 md:pr-3">
<FormInfo>
<FormLabel htmlFor="abv">ABV</FormLabel>
<FormError>{errors.abv?.message}</FormError>
</FormInfo>
<FormTextInput
disabled={isSubmitting}
placeholder="12"
formValidationSchema={register('abv', { valueAsNumber: true })}
error={!!errors.abv}
type="text"
id="abv"
/>
</div>
<div className="mb-2 w-full md:mb-0 md:w-1/2 md:pl-3">
<FormInfo>
<FormLabel htmlFor="ibu">IBU</FormLabel>
<FormError>{errors.ibu?.message}</FormError>
</FormInfo>
<FormTextInput
disabled={isSubmitting}
placeholder="52"
formValidationSchema={register('ibu', { valueAsNumber: true })}
error={!!errors.ibu}
type="text"
id="lastName"
/>
</div>
</div>
<FormInfo>
<FormLabel htmlFor="description">Description</FormLabel>
<FormError>{errors.description?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
disabled={isSubmitting}
placeholder="Ratione cumque quas quia aut impedit ea culpa facere. Ut in sit et quas reiciendis itaque."
error={!!errors.description}
formValidationSchema={register('description')}
id="description"
rows={8}
/>
</FormSegment>
<div className="mt-2 space-y-4">
<Button type="submit" isSubmitting={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
<button
className={`btn btn-primary w-full rounded-xl ${isSubmitting ? 'loading' : ''}`}
type="button"
onClick={onDelete}
>
Delete
</button>
</div>
</form>
);
};
export default EditBeerPostForm;

View File

@@ -1,106 +0,0 @@
import EditBreweryPostValidationSchema from '@/services/posts/brewery-post/schema/EditBreweryPostValidationSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FC, useState } from 'react';
import BreweryPostQueryResult from '@/services/posts/brewery-post/schema/BreweryPostQueryResult';
import {
sendDeleteBreweryPostRequest,
sendEditBreweryPostRequest,
} from '@/requests/posts/brewery-post';
import FormError from './ui/forms/FormError';
import FormInfo from './ui/forms/FormInfo';
import FormLabel from './ui/forms/FormLabel';
import FormSegment from './ui/forms/FormSegment';
import FormTextArea from './ui/forms/FormTextArea';
import FormTextInput from './ui/forms/FormTextInput';
interface EditBreweryPostFormProps {
breweryPost: z.infer<typeof BreweryPostQueryResult>;
}
const EditBreweryPostForm: FC<EditBreweryPostFormProps> = ({ breweryPost }) => {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<z.infer<typeof EditBreweryPostValidationSchema>>({
resolver: zodResolver(EditBreweryPostValidationSchema),
defaultValues: {
name: breweryPost.name,
description: breweryPost.description,
id: breweryPost.id,
dateEstablished: breweryPost.dateEstablished,
},
});
const [isDeleting, setIsDeleting] = useState(false);
const onSubmit = async (data: z.infer<typeof EditBreweryPostValidationSchema>) => {
await sendEditBreweryPostRequest({ breweryPostId: breweryPost.id, body: data });
await router.push(`/breweries/${breweryPost.id}`);
};
const handleDelete = async () => {
setIsDeleting(true);
await sendDeleteBreweryPostRequest({ breweryPostId: breweryPost.id });
await router.push('/breweries');
};
return (
<form className="form-control space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="w-full">
<FormInfo>
<FormLabel htmlFor="name">Name</FormLabel>
<FormError>{errors.name?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
id="name"
type="text"
placeholder="Name"
formValidationSchema={register('name')}
error={!!errors.name}
disabled={isSubmitting || isDeleting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="description">Description</FormLabel>
<FormError>{errors.description?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
disabled={isSubmitting || isDeleting}
placeholder="Ratione cumque quas quia aut impedit ea culpa facere. Ut in sit et quas reiciendis itaque."
error={!!errors.description}
formValidationSchema={register('description')}
id="description"
rows={8}
/>
</FormSegment>
</div>
<div className="w-full space-y-3">
<button
disabled={isSubmitting || isDeleting}
className="btn btn-primary w-full"
type="submit"
>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
<button
className="btn btn-primary w-full"
type="button"
disabled={isSubmitting || isDeleting}
onClick={handleDelete}
>
Delete Brewery
</button>
</div>
</form>
);
};
export default EditBreweryPostForm;

View File

@@ -1,92 +0,0 @@
import LoginValidationSchema from '@/services/users/auth/schema/LoginValidationSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router';
import { useContext } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod';
import UserContext from '@/contexts/UserContext';
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';
import FormSegment from '../ui/forms/FormSegment';
import FormTextInput from '../ui/forms/FormTextInput';
import Button from '../ui/forms/Button';
import { sendLoginUserRequest } from '@/requests/users/auth';
type LoginT = z.infer<typeof LoginValidationSchema>;
const LoginForm = () => {
const router = useRouter();
const { register, handleSubmit, formState, reset } = useForm<LoginT>({
resolver: zodResolver(LoginValidationSchema),
defaultValues: {
username: '',
password: '',
},
});
const { errors } = formState;
const { mutate } = useContext(UserContext);
const onSubmit: SubmitHandler<LoginT> = async (data) => {
const loadingToast = toast.loading('Logging in...');
try {
await sendLoginUserRequest(data);
await mutate!();
toast.remove(loadingToast);
toast.success('Logged in!');
await router.push(`/users/current`);
} catch (error) {
toast.remove(loadingToast);
createErrorToast(error);
reset();
}
};
return (
<form className="form-control space-y-5" onSubmit={handleSubmit(onSubmit)}>
<div>
<FormInfo>
<FormLabel htmlFor="username">username</FormLabel>
<FormError>{errors.username?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
id="username"
type="text"
formValidationSchema={register('username')}
disabled={formState.isSubmitting}
error={!!errors.username}
placeholder="username"
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="password">password</FormLabel>
<FormError>{errors.password?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
disabled={formState.isSubmitting}
id="password"
type="password"
formValidationSchema={register('password')}
error={!!errors.password}
placeholder="password"
/>
</FormSegment>
</div>
<div className="w-full">
<Button type="submit" isSubmitting={formState.isSubmitting}>
Login
</Button>
</div>
</form>
);
};
export default LoginForm;

View File

@@ -1,180 +0,0 @@
import { CreateUserValidationSchemaWithUsernameAndEmailCheck } from '@/services/users/auth/schema/CreateUserValidationSchemas';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router';
import { FC } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import createErrorToast from '@/util/createErrorToast';
import toast from 'react-hot-toast';
import { sendRegisterUserRequest } from '@/requests/users/auth';
import Button from './ui/forms/Button';
import FormError from './ui/forms/FormError';
import FormInfo from './ui/forms/FormInfo';
import FormLabel from './ui/forms/FormLabel';
import FormSegment from './ui/forms/FormSegment';
import FormTextInput from './ui/forms/FormTextInput';
const RegisterUserForm: FC = () => {
const router = useRouter();
const { reset, register, handleSubmit, formState } = useForm<
z.infer<typeof CreateUserValidationSchemaWithUsernameAndEmailCheck>
>({ resolver: zodResolver(CreateUserValidationSchemaWithUsernameAndEmailCheck) });
const { errors } = formState;
const onSubmit = async (
data: z.infer<typeof CreateUserValidationSchemaWithUsernameAndEmailCheck>,
) => {
try {
const loadingToast = toast.loading('Registering user...');
await sendRegisterUserRequest(data);
reset();
router.push('/', undefined, { shallow: true });
toast.remove(loadingToast);
toast.success('User registered!');
} catch (error) {
createErrorToast({
toast,
error,
});
}
};
return (
<form className="form-control w-full" noValidate onSubmit={handleSubmit(onSubmit)}>
<div className="lg:space-y-5">
<div className="flex flex-col lg:flex-row lg:space-x-5">
<div className="lg:w-[50%]">
<FormInfo>
<FormLabel htmlFor="firstName">First name</FormLabel>
<FormError>{errors.firstName?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
disabled={formState.isSubmitting}
id="firstName"
type="text"
formValidationSchema={register('firstName')}
error={!!errors.firstName}
placeholder="John"
/>
</FormSegment>
</div>
<div className="lg:w-[50%]">
<FormInfo>
<FormLabel htmlFor="lastName">Last name</FormLabel>
<FormError>{errors.lastName?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
disabled={formState.isSubmitting}
id="lastName"
type="text"
formValidationSchema={register('lastName')}
error={!!errors.lastName}
placeholder="Doe"
/>
</FormSegment>
</div>
</div>
<div className="flex flex-col lg:flex-row lg:space-x-5">
<div className="lg:w-[50%]">
<FormInfo>
<FormLabel htmlFor="email">email</FormLabel>
<FormError>{errors.email?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
disabled={formState.isSubmitting}
id="email"
type="email"
formValidationSchema={register('email')}
error={!!errors.email}
placeholder="john.doe@example.com"
/>
</FormSegment>
</div>
<div className="lg:w-[50%]">
<FormInfo>
<FormLabel htmlFor="username">username</FormLabel>
<FormError>{errors.username?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
disabled={formState.isSubmitting}
id="username"
type="text"
formValidationSchema={register('username')}
error={!!errors.username}
placeholder="johndoe"
/>
</FormSegment>
</div>
</div>
<div className="flex flex-col lg:flex-row lg:space-x-5">
<div className="lg:w-[50%]">
<FormInfo>
<FormLabel htmlFor="password">password</FormLabel>
<FormError>{errors.password?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
disabled={formState.isSubmitting}
id="password"
type="password"
formValidationSchema={register('password')}
error={!!errors.password}
placeholder="password"
/>
</FormSegment>
</div>
<div className="lg:w-[50%]">
<FormInfo>
<FormLabel htmlFor="confirmPassword">confirm password</FormLabel>
<FormError>{errors.confirmPassword?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
disabled={formState.isSubmitting}
id="confirmPassword"
type="password"
formValidationSchema={register('confirmPassword')}
error={!!errors.confirmPassword}
placeholder="confirm password"
/>
</FormSegment>
</div>
</div>
<div>
<FormInfo>
<FormLabel htmlFor="dateOfBirth">Date of birth</FormLabel>
<FormError>{errors.dateOfBirth?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
id="dateOfBirth"
disabled={formState.isSubmitting}
type="date"
formValidationSchema={register('dateOfBirth')}
error={!!errors.dateOfBirth}
placeholder="date of birth"
/>
</FormSegment>
</div>
</div>
<div className="mt-10">
<Button type="submit" isSubmitting={formState.isSubmitting}>
Register User
</Button>
</div>
</form>
);
};
export default RegisterUserForm;

View File

@@ -1,67 +0,0 @@
import useFollowStatus from '@/hooks/data-fetching/user-follows/useFollowStatus';
import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser';
import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser';
import { sendUserFollowRequest } from '@/requests/users/auth';
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
import { FC, useState } from 'react';
import { FaUserCheck, FaUserPlus } from 'react-icons/fa';
import { z } from 'zod';
interface UserFollowButtonProps {
mutateFollowerCount: ReturnType<typeof useGetUsersFollowingUser>['mutate'];
mutateFollowingCount: ReturnType<typeof useGetUsersFollowedByUser>['mutate'];
user: z.infer<typeof GetUserSchema>;
}
const UserFollowButton: FC<UserFollowButtonProps> = ({
user,
mutateFollowerCount,
mutateFollowingCount,
}) => {
const { isFollowed, mutate: mutateFollowStatus } = useFollowStatus(user.id);
const [isLoading, setIsLoading] = useState(false);
const onClick = async () => {
try {
setIsLoading(true);
await sendUserFollowRequest({ userId: user.id });
await Promise.all([
mutateFollowStatus(),
mutateFollowerCount(),
mutateFollowingCount(),
]);
setIsLoading(false);
} catch (e) {
setIsLoading(false);
}
};
return (
<button
type="button"
className={`btn btn-sm gap-2 rounded-2xl lg:btn-md ${
!isFollowed ? 'btn-ghost outline' : 'btn-primary'
}`}
onClick={() => {
onClick();
}}
disabled={isLoading}
>
{isFollowed ? (
<>
<FaUserCheck className="text-xl" />
Followed
</>
) : (
<>
<FaUserPlus className="text-xl" />
Follow
</>
)}
</button>
);
};
export default UserFollowButton;

View File

@@ -1,85 +0,0 @@
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import { FC, useContext } from 'react';
import { z } from 'zod';
import { format } from 'date-fns';
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser';
import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser';
import UserContext from '@/contexts/UserContext';
import Link from 'next/link';
import UserAvatar from '../Account/UserAvatar';
import UserFollowButton from './UserFollowButton';
interface UserHeaderProps {
user: z.infer<typeof GetUserSchema>;
}
const UserHeader: FC<UserHeaderProps> = ({ user }) => {
const timeDistance = useTimeDistance(new Date(user.createdAt));
const { followingCount, mutate: mutateFollowingCount } = useGetUsersFollowedByUser({
userId: user.id,
pageSize: 10,
});
const { followerCount, mutate: mutateFollowerCount } = useGetUsersFollowingUser({
userId: user.id,
pageSize: 10,
});
const { user: currentUser } = useContext(UserContext);
return (
<header className="card items-center text-center">
<div className="card-body w-full items-center justify-center">
<div className="h-40 w-40">
<UserAvatar user={user} />
</div>
<div>
<h1 className="text-2xl font-bold lg:text-4xl">{user.username}</h1>
<div className="flex space-x-3 text-lg font-bold">
<span>{followingCount} Following</span>
<span>{followerCount} Followers</span>
</div>
</div>
<div>
<span className="italic">
joined{' '}
{timeDistance && (
<span
className="tooltip tooltip-bottom"
data-tip={format(new Date(user.createdAt), 'MM/dd/yyyy')}
>
{`${timeDistance} ago`}
</span>
)}
</span>
</div>
{user.bio && (
<div className="my-2 w-6/12">
<p className="text-sm">{user.bio}</p>
</div>
)}
<div className="my-2 flex items-center justify-center">
{currentUser?.id !== user.id ? (
<UserFollowButton
mutateFollowerCount={mutateFollowerCount}
user={user}
mutateFollowingCount={mutateFollowingCount}
/>
) : (
<Link href={`/users/account/edit-profile`} className="btn btn-primary btn-sm">
Edit Profile
</Link>
)}
</div>
</div>
</header>
);
};
export default UserHeader;

View File

@@ -1,56 +0,0 @@
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 (
<>
<Toaster
position="bottom-right"
toastOptions={{
duration: 2500,
}}
>
{(t) => {
const alertType = toastToClassName(t.type);
return (
<div
className={`alert ${alertType} flex w-full items-start justify-between shadow-lg duration-200 animate-in fade-in lg:w-4/12`}
>
<p className="w-full text-left">{resolveValue(t.message, t)}</p>
{t.type !== 'loading' && (
<div>
<button
className="btn btn-circle btn-ghost btn-xs"
onClick={() => toast.dismiss(t.id)}
>
<FaTimes />
</button>
</div>
)}
</div>
);
}}
</Toaster>
{children}
</>
);
};
export default CustomToast;

View File

@@ -1,12 +0,0 @@
import { FC, ReactNode } from 'react';
import Navbar from './Navbar';
const Layout: FC<{ children: ReactNode }> = ({ children }) => {
return (
<div className="flex h-full flex-col" id="app">
<Navbar />
{children}
</div>
);
};
export default Layout;

View File

@@ -1,37 +0,0 @@
import { FC } from 'react';
import { FaThumbsUp, FaRegThumbsUp } from 'react-icons/fa';
interface LikeButtonProps {
isLiked: boolean;
handleLike: () => Promise<void>;
loading: boolean;
}
const LikeButton: FC<LikeButtonProps> = ({ isLiked, handleLike, loading }) => {
return (
<button
type="button"
className={`btn btn-sm gap-2 rounded-2xl lg:btn-md ${
!isLiked ? 'btn-ghost outline' : 'btn-primary'
}`}
onClick={() => {
handleLike();
}}
disabled={loading}
>
{isLiked ? (
<>
<FaThumbsUp className="lg:text-2xl" />
Liked
</>
) : (
<>
<FaRegThumbsUp className="lg:text-2xl" />
Like
</>
)}
</button>
);
};
export default LikeButton;

View File

@@ -1,26 +0,0 @@
import { FC } from 'react';
const LoadingCard: FC = () => {
return (
<div className="card bg-base-300">
<figure className="h-96 border-8 border-base-300 bg-base-300">
<div className="h-full w-full animate-pulse rounded-md bg-base-100" />
</figure>
<div className="card-body h-52">
<div className="flex animate-pulse space-x-4">
<div className="flex-1 space-y-4 py-1">
<div className="h-4 w-3/4 rounded bg-base-100" />
<div className="space-y-2">
<div className="h-4 rounded bg-base-100" />
<div className="h-4 w-5/6 rounded bg-base-100" />
<div className="h-4 w-5/6 rounded bg-base-100" />
<div className="h-4 rounded bg-base-100" />
</div>
</div>
</div>
</div>
</div>
);
};
export default LoadingCard;

View File

@@ -1,20 +0,0 @@
import React, { FC } from 'react';
import { HiLocationMarker } from 'react-icons/hi';
interface LocationMarkerProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
color?: 'blue' | 'red' | 'green' | 'yellow';
}
const sizeClasses: Record<NonNullable<LocationMarkerProps['size']>, `text-${string}`> = {
sm: 'text-lg',
md: 'text-xl',
lg: 'text-2xl',
xl: 'text-3xl',
};
const LocationMarker: FC<LocationMarkerProps> = ({ size = 'md', color = 'blue' }) => {
return <HiLocationMarker className={`${sizeClasses[size]} text-${color}-600`} />;
};
export default React.memo(LocationMarker);

View File

@@ -1,138 +0,0 @@
import useMediaQuery from '@/hooks/utilities/useMediaQuery';
import useNavbar from '@/hooks/utilities/useNavbar';
// import useTheme from '@/hooks/utilities/useTheme';
import Link from 'next/link';
import { FC, useRef } from 'react';
// import { MdDarkMode, MdLightMode } from 'react-icons/md';
import { FaBars } from 'react-icons/fa';
import classNames from 'classnames';
const DesktopLinks: FC = () => {
const { pages, currentURL } = useNavbar();
return (
<div className="block flex-none">
<ul className="menu menu-horizontal menu-sm">
{pages.map((page) => {
return (
<li key={page.slug}>
<Link tabIndex={0} href={page.slug} className="hover:bg-primary-focus">
<span
className={`text-lg uppercase ${
currentURL === page.slug ? 'font-extrabold' : 'font-bold'
} text-base-content`}
>
{page.name}
</span>
</Link>
</li>
);
})}
</ul>
</div>
);
};
const MobileLinks: FC = () => {
const { pages } = useNavbar();
const drawerRef = useRef<HTMLInputElement>(null);
return (
<div className="flex-none lg:hidden">
<div className="drawer drawer-end">
<input id="my-drawer" type="checkbox" className="drawer-toggle" ref={drawerRef} />
<div className="drawer-content">
<label htmlFor="my-drawer" className="btn btn-ghost drawer-button">
<FaBars />
</label>
</div>
<div className="drawer-side">
<label
htmlFor="my-drawer"
aria-label="close sidebar"
className="drawer-overlay"
/>
<ul className="menu min-h-full bg-primary pr-16 text-base-content">
{pages.map((page) => {
return (
<li key={page.slug}>
<Link
href={page.slug}
tabIndex={0}
rel={page.slug === '/resume/main.pdf' ? 'noopener noreferrer' : ''}
target={page.slug === '/resume/main.pdf' ? '_blank' : ''}
onClick={() => {
if (!drawerRef.current) return;
drawerRef.current.checked = false;
}}
>
<span className="text-lg font-bold uppercase">{page.name}</span>
</Link>
</li>
);
})}
</ul>
</div>
</div>
</div>
);
};
const Navbar = () => {
const isDesktopView = useMediaQuery('(min-width: 1024px)');
const { currentURL } = useNavbar();
const backgroundIsTransparent = currentURL === '/';
const isOnHomePage = currentURL === '/';
// const { theme, setTheme } = useTheme();
return (
<div
className={classNames('navbar fixed top-0 z-20 h-10 min-h-10 text-base-content', {
'bg-transparent': backgroundIsTransparent,
'bg-primary': !backgroundIsTransparent,
})}
>
<div className="flex-1">
{isOnHomePage ? null : (
<Link className="btn btn-ghost btn-sm" href="/">
<span className="cursor-pointer text-lg font-bold">The Biergarten App</span>
</Link>
)}
</div>
{/* <div
className="tooltip tooltip-left"
data-tip={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
>
<div>
{theme === 'light' ? (
<button
className="btn btn-circle btn-ghost btn-md"
data-set-theme="dark"
data-act-class="ACTIVECLASS"
onClick={() => setTheme('dark')}
>
<MdLightMode className="text-xl" />
</button>
) : (
<button
className="btn btn-circle btn-ghost btn-md"
data-set-theme="light"
data-act-class="ACTIVECLASS"
onClick={() => setTheme('light')}
>
<MdDarkMode className="text-xl" />
</button>
)}
</div>
</div> */}
<div>{isDesktopView ? <DesktopLinks /> : <MobileLinks />}</div>
</div>
);
};
export default Navbar;

View File

@@ -1,23 +0,0 @@
import { FC } from 'react';
const SMLoadingCard: FC = () => {
return (
<div className="card bg-base-300">
<div className="card-body h-52">
<div className="flex animate-pulse space-x-4">
<div className="flex-1 space-y-4 py-1">
<div className="h-4 w-3/4 rounded bg-base-100" />
<div className="space-y-2">
<div className="h-4 rounded bg-base-100" />
<div className="h-4 w-5/6 rounded bg-base-100" />
<div className="h-4 w-5/6 rounded bg-base-100" />
<div className="h-4 rounded bg-base-100" />
</div>
</div>
</div>
</div>
</div>
);
};
export default SMLoadingCard;

View File

@@ -1,44 +0,0 @@
import { FC } from 'react';
interface SpinnerProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}
const Spinner: FC<SpinnerProps> = ({ size = 'md' }) => {
const spinnerWidths: Record<NonNullable<SpinnerProps['size']>, `w-[${number}px]`> = {
xs: 'w-[45px]',
sm: 'w-[90px]',
md: 'w-[135px]',
lg: 'w-[180px]',
xl: 'w-[225px]',
};
return (
<div
role="alert"
aria-busy="true"
aria-live="polite"
className="flex flex-col items-center justify-center rounded-3xl text-primary"
>
<svg
aria-hidden="true"
className={`${spinnerWidths[size]} animate-spin fill-base-content`}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
);
};
export default Spinner;

View File

@@ -1,24 +0,0 @@
import { FunctionComponent } from 'react';
interface FormButtonProps {
children: string;
type: 'button' | 'submit' | 'reset';
isSubmitting?: boolean;
}
const Button: FunctionComponent<FormButtonProps> = ({
children,
type,
isSubmitting = false,
}) => (
// eslint-disable-next-line react/button-has-type
<button
type={type}
className={`btn btn-primary w-full rounded-xl`}
disabled={isSubmitting}
>
{children}
</button>
);
export default Button;

View File

@@ -1,16 +0,0 @@
import { FunctionComponent } from 'react';
/**
* @example
* <FormError>Something went wrong!</FormError>;
*/
const FormError: FunctionComponent<{ children: string | undefined }> = ({ children }) =>
children ? (
<div
className="my-1 h-3 text-xs font-semibold italic text-error-content"
role="alert"
>
{children}
</div>
) : null;
export default FormError;

View File

@@ -1,18 +0,0 @@
import { FunctionComponent, ReactNode } from 'react';
interface FormInfoProps {
children: [ReactNode, ReactNode];
}
/**
* @example
* <FormInfo>
* <FormLabel htmlFor="name">Name</FormLabel>
* <FormError>{errors.name?.message}</FormError>
* </FormInfo>;
*/
const FormInfo: FunctionComponent<FormInfoProps> = ({ children }) => (
<div className="flex justify-between">{children}</div>
);
export default FormInfo;

View File

@@ -1,21 +0,0 @@
import { FunctionComponent } from 'react';
interface FormLabelProps {
htmlFor: string;
children: string;
}
/**
* @example
* <FormLabel htmlFor="name">Name</FormLabel>;
*/
const FormLabel: FunctionComponent<FormLabelProps> = ({ htmlFor, children }) => (
<label
className="my-1 block text-xs font-extrabold uppercase tracking-wide lg:text-sm"
htmlFor={htmlFor}
>
{children}
</label>
);
export default FormLabel;

View File

@@ -1,39 +0,0 @@
import { ReactNode, FC } from 'react';
import Link from 'next/link';
import { IconType } from 'react-icons';
import { BiArrowBack } from 'react-icons/bi';
interface FormPageLayoutProps {
children: ReactNode;
headingText: string;
headingIcon: IconType;
backLink: string;
backLinkText: string;
}
const FormPageLayout: FC<FormPageLayoutProps> = ({
children: FormComponent,
headingIcon,
headingText,
backLink,
backLinkText,
}) => {
return (
<div className="my-20 flex flex-col items-center justify-center">
<div className="w-11/12 lg:w-9/12 2xl:w-7/12">
<div className="tooltip tooltip-right" data-tip={backLinkText}>
<Link href={backLink} className="btn btn-ghost btn-sm p-0">
<BiArrowBack className="text-xl" />
</Link>
</div>
<div className="flex flex-col items-center space-y-1">
{headingIcon({ className: 'text-4xl' })}{' '}
<h1 className="text-center text-3xl font-bold">{headingText}</h1>
</div>
<div className="mt-3">{FormComponent}</div>
</div>
</div>
);
};
export default FormPageLayout;

View File

@@ -1,12 +0,0 @@
import { FunctionComponent } from 'react';
/** A container for both the form error and form label. */
interface FormInfoProps {
children: Array<JSX.Element> | JSX.Element;
}
const FormSegment: FunctionComponent<FormInfoProps> = ({ children }) => (
<div className="mb-2">{children}</div>
);
export default FormSegment;

View File

@@ -1,64 +0,0 @@
import { FunctionComponent } from 'react';
import { UseFormRegisterReturn } from 'react-hook-form';
interface FormSelectProps {
options: readonly { value: string; text: string }[];
id: string;
formRegister: UseFormRegisterReturn<string>;
error: boolean;
placeholder: string;
message: string;
disabled?: boolean;
}
/**
* @example
* <FormSelect
* options={[
* { value: '1', text: 'One' },
* { value: '2', text: 'Two' },
* { value: '3', text: 'Three' },
* ]}
* id="test"
* formRegister={register('test')}
* error={true}
* placeholder="Test"
* message="Select an option"
* />;
*
* @param props
* @param props.options The options to display in the select.
* @param props.id The id of the select.
* @param props.formRegister The form register hook from react-hook-form.
* @param props.error Whether or not the select has an error.
* @param props.placeholder The placeholder text for the select.
* @param props.message The message to display when no option is selected.
*/
const FormSelect: FunctionComponent<FormSelectProps> = ({
options,
id,
error,
formRegister,
placeholder,
message,
disabled = false,
}) => (
<select
id={id}
className={`select select-bordered block w-full rounded-lg ${
error ? 'select-error' : ''
}`}
placeholder={placeholder}
disabled={disabled}
{...formRegister}
>
<option value="">{message}</option>
{options.map(({ value, text }) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
);
export default FormSelect;

View File

@@ -1,52 +0,0 @@
import { FunctionComponent } from 'react';
import { UseFormRegisterReturn } from 'react-hook-form';
interface FormTextAreaProps {
placeholder?: string;
formValidationSchema: UseFormRegisterReturn<string>;
error: boolean;
id: string;
rows: number;
disabled?: boolean;
}
/**
* @example
* <FormTextArea
* id="test"
* formValidationSchema={register('test')}
* error={true}
* placeholder="Test"
* rows={5}
* disabled
* />;
*
* @param props
* @param props.placeholder The placeholder text for the textarea.
* @param props.formValidationSchema The form register hook from react-hook-form.
* @param props.error Whether or not the textarea has an error.
* @param props.id The id of the textarea.
* @param props.rows The number of rows to display in the textarea.
* @param props.disabled Whether or not the textarea is disabled.
*/
const FormTextArea: FunctionComponent<FormTextAreaProps> = ({
placeholder = '',
formValidationSchema,
error,
id,
rows,
disabled = false,
}) => (
<textarea
id={id}
placeholder={placeholder}
className={`text-md textarea textarea-bordered m-0 w-full resize-none rounded-lg border border-solid transition ease-in-out ${
error ? 'textarea-error' : ''
}`}
{...formValidationSchema}
rows={rows}
disabled={disabled}
/>
);
export default FormTextArea;

View File

@@ -1,59 +0,0 @@
/* eslint-disable react/require-default-props */
import { FunctionComponent } from 'react';
import { UseFormRegisterReturn } from 'react-hook-form';
interface FormInputProps {
placeholder?: string;
formValidationSchema: UseFormRegisterReturn<string>;
error: boolean;
// eslint-disable-next-line react/require-default-props
type: 'email' | 'password' | 'text' | 'date';
id: string;
height?: string;
disabled?: boolean;
autoComplete?: string;
}
/**
* @example
* <FormTextInput
* placeholder="Lorem Ipsum Lager"
* formValidationSchema={register('name')}
* error={!!errors.name}
* type="text"
* id="name"
* disabled
* />;
*
* @param param0 The props for the FormTextInput component
* @param param0.placeholder The placeholder text for the input
* @param param0.formValidationSchema The validation schema for the input, provided by
* react-hook-form.
* @param param0.error Whether or not the input has an error.
* @param param0.type The input type (email, password, text, date).
* @param param0.id The id of the input.
* @param param0.height The height of the input.
* @param param0.disabled Whether or not the input is disabled.
* @param param0.autoComplete The autocomplete value for the input.
*/
const FormTextInput: FunctionComponent<FormInputProps> = ({
placeholder = '',
formValidationSchema,
error,
type,
id,
disabled = false,
}) => (
<input
id={id}
type={type}
placeholder={placeholder}
className={`input input-bordered w-full appearance-none rounded-lg transition ease-in-out ${
error ? 'input-error' : ''
}`}
{...formValidationSchema}
disabled={disabled}
/>
);
export default FormTextInput;

View File

@@ -1,12 +0,0 @@
import { FC, memo } from 'react';
import { FullscreenControl, NavigationControl, ScaleControl } from 'react-map-gl';
const ControlPanel: FC = () => (
<>
<FullscreenControl position="top-left" />
<NavigationControl position="top-left" />
<ScaleControl />
</>
);
export default memo(ControlPanel);

View File

@@ -1,35 +0,0 @@
import { NextApiResponse } from 'next';
import { serialize, parse } from 'cookie';
import { SessionRequest } from './types';
import { NODE_ENV, SESSION_MAX_AGE, SESSION_TOKEN_NAME } from '../env';
export function setTokenCookie(res: NextApiResponse, token: string) {
const cookie = serialize(SESSION_TOKEN_NAME, token, {
maxAge: SESSION_MAX_AGE,
httpOnly: false,
secure: NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
});
res.setHeader('Set-Cookie', cookie);
}
export function removeTokenCookie(res: NextApiResponse) {
const cookie = serialize(SESSION_TOKEN_NAME, '', { maxAge: -1, path: '/' });
res.setHeader('Set-Cookie', cookie);
}
export function parseCookies(req: SessionRequest) {
// For API Routes we don't need to parse the cookies.
if (req.cookies) return req.cookies;
// For pages we do need to parse the cookies.
const cookie = req.headers?.cookie;
return parse(cookie || '');
}
export function getTokenCookie(req: SessionRequest) {
const cookies = parseCookies(req);
return cookies[SESSION_TOKEN_NAME];
}

View File

@@ -1,24 +0,0 @@
import Local from 'passport-local';
import { findUserByUsernameService } from '@/services/users/auth';
import ServerError from '../util/ServerError';
import { validatePassword } from './passwordFns';
const localStrat = new Local.Strategy(async (username, password, done) => {
try {
const user = await findUserByUsernameService({ username });
if (!user) {
throw new ServerError('Username or password is incorrect.', 401);
}
const isValidLogin = await validatePassword(user.hash, password);
if (!isValidLogin) {
throw new ServerError('Username or password is incorrect.', 401);
}
done(null, { id: user.id, username: user.username });
} catch (error) {
done(error);
}
});
export default localStrat;

View File

@@ -1,6 +0,0 @@
import argon2 from 'argon2';
export const hashPassword = async (password: string) => argon2.hash(password);
export const validatePassword = async (hash: string, password: string) =>
argon2.verify(hash, password);

View File

@@ -1,53 +0,0 @@
import { NextApiResponse } from 'next';
import Iron from '@hapi/iron';
import {
SessionRequest,
BasicUserInfoSchema,
UserSessionSchema,
} from '@/config/auth/types';
import { z } from 'zod';
import { SESSION_MAX_AGE, SESSION_SECRET } from '@/config/env';
import { setTokenCookie, getTokenCookie } from './cookie';
import ServerError from '../util/ServerError';
export async function setLoginSession(
res: NextApiResponse,
session: z.infer<typeof BasicUserInfoSchema>,
) {
if (!SESSION_SECRET) {
throw new ServerError('Authentication is not configured.', 500);
}
const createdAt = Date.now();
const obj = { ...session, createdAt, maxAge: SESSION_MAX_AGE };
const token = await Iron.seal(obj, SESSION_SECRET, Iron.defaults);
setTokenCookie(res, token);
}
export async function getLoginSession(req: SessionRequest) {
if (!SESSION_SECRET) {
throw new ServerError('Authentication is not configured.', 500);
}
const token = getTokenCookie(req);
if (!token) {
throw new ServerError('You are not logged in.', 401);
}
const session = await Iron.unseal(token, SESSION_SECRET, Iron.defaults);
const parsed = UserSessionSchema.safeParse(session);
if (!parsed.success) {
throw new ServerError('Session is invalid.', 401);
}
const { createdAt, maxAge } = parsed.data;
const expiresAt = createdAt + maxAge * 1000;
if (Date.now() > expiresAt) {
throw new ServerError('Session expired', 401);
}
return parsed.data;
}

View File

@@ -1,26 +0,0 @@
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
import { IncomingMessage } from 'http';
import { NextApiRequest } from 'next';
import { z } from 'zod';
export const BasicUserInfoSchema = z.object({
id: z.string().cuid(),
username: z.string(),
});
export const UserSessionSchema = BasicUserInfoSchema.merge(
z.object({
createdAt: z.number(),
maxAge: z.number(),
}),
);
export interface UserExtendedNextApiRequest extends NextApiRequest {
user?: z.infer<typeof GetUserSchema>;
}
export type SessionRequest = IncomingMessage & {
cookies: Partial<{
[key: string]: string;
}>;
};

View File

@@ -1,95 +0,0 @@
/* eslint-disable no-underscore-dangle */
import type { StorageEngine } from 'multer';
import type { UploadApiOptions, UploadApiResponse, v2 as cloudinary } from 'cloudinary';
import type { Request } from 'express';
/**
* Represents a storage engine for uploading files to Cloudinary.
*
* @example
* const storage = new CloudinaryStorage({
* cloudinary,
* params: {
* folder: 'my-folder',
* allowed_formats: ['jpg', 'png'],
* },
* });
*/
class CloudinaryStorage implements StorageEngine {
private cloudinary: typeof cloudinary;
private params: UploadApiOptions;
/**
* Creates an instance of CloudinaryStorage.
*
* @param options - The options for configuring the Cloudinary storage engine.
* @param options.cloudinary - The Cloudinary instance.
* @param options.params - The parameters for uploading files to Cloudinary.
*/
constructor(options: { cloudinary: typeof cloudinary; params: UploadApiOptions }) {
this.cloudinary = options.cloudinary;
this.params = options.params;
}
/**
* Removes the file from Cloudinary.
*
* @param req - The request object.
* @param file - The file to be removed.
* @param callback - The callback function to be called if an error occurs.
*/
_removeFile(req: Request, file: Express.Multer.File, callback: (error: Error) => void) {
this.cloudinary.uploader.destroy(file.filename, { invalidate: true }, callback);
}
/**
* Handles the file upload to Cloudinary.
*
* @param req - The request object.
* @param file - The file to be uploaded.
* @param callback - The callback function to be called after the file is uploaded.
*/
_handleFile(
req: Request,
file: Express.Multer.File,
callback: (error?: unknown, info?: Partial<Express.Multer.File>) => void,
) {
this.uploadFile(file)
.then((cloudResponse) => {
callback(null, {
path: cloudResponse.secure_url,
size: cloudResponse.bytes,
filename: cloudResponse.public_id,
});
})
.catch((error) => {
callback(error);
});
}
/**
* Uploads a file to Cloudinary.
*
* @param file - The file to be uploaded.
* @returns A promise that resolves to the upload response.
*/
private uploadFile(file: Express.Multer.File): Promise<UploadApiResponse> {
return new Promise((resolve, reject) => {
const stream = this.cloudinary.uploader.upload_stream(
this.params,
(err, response) => {
if (err != null) {
return reject(err);
}
return resolve(response!);
},
);
file.stream.pipe(stream);
});
}
}
export default CloudinaryStorage;

View File

@@ -1,11 +0,0 @@
import { cloudinary } from '..';
/**
* Deletes an image from Cloudinary.
*
* @param path - The cloudinary path of the image to be deleted.
* @returns A promise that resolves when the image is successfully deleted.
*/
const deleteImage = (path: string) => cloudinary.uploader.destroy(path);
export default deleteImage;

View File

@@ -1,23 +0,0 @@
import { v2 as cloudinary } from 'cloudinary';
import {
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
CLOUDINARY_KEY,
CLOUDINARY_SECRET,
NODE_ENV,
} from '../env';
import CloudinaryStorage from './CloudinaryStorage';
cloudinary.config({
cloud_name: NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
api_key: CLOUDINARY_KEY,
api_secret: CLOUDINARY_SECRET,
});
/** Cloudinary storage instance. */
const storage = new CloudinaryStorage({
cloudinary,
params: { folder: NODE_ENV === 'production' ? 'biergarten' : 'biergarten-dev' },
});
export { cloudinary, storage };

View File

@@ -1,210 +0,0 @@
/* eslint-disable prefer-destructuring */
import { z } from 'zod';
import { env } from 'process';
import ServerError from '../util/ServerError';
import 'dotenv/config';
/**
* Environment variables are validated at runtime to ensure that they are present and have
* the correct type. This is done using the zod library.
*/
const envSchema = z.object({
BASE_URL: z.string().url(),
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME: z.string(),
CLOUDINARY_KEY: z.string(),
CLOUDINARY_SECRET: z.string(),
RESET_PASSWORD_TOKEN_SECRET: z.string(),
CONFIRMATION_TOKEN_SECRET: z.string(),
SESSION_SECRET: z.string(),
SESSION_TOKEN_NAME: z.string(),
SESSION_MAX_AGE: z.coerce.number().positive(),
POSTGRES_PRISMA_URL: z.string().url(),
POSTGRES_URL_NON_POOLING: z.string().url(),
SHADOW_DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']),
SPARKPOST_API_KEY: z.string(),
SPARKPOST_SENDER_ADDRESS: z.string().email(),
MAPBOX_ACCESS_TOKEN: z.string(),
ADMIN_PASSWORD: z.string().regex(/^(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9]).{8,}$/),
});
const parsed = envSchema.safeParse(env);
if (!parsed.success) {
throw new ServerError('Invalid environment variables', 500);
}
/**
* Base URL of the application.
*
* @example
* 'https://example.com';
*/
export const BASE_URL = parsed.data.BASE_URL;
/**
* Cloudinary cloud name.
*
* @example
* 'my-cloud';
*
* @see https://cloudinary.com/documentation/cloudinary_references
* @see https://cloudinary.com/console
*/
export const NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME =
parsed.data.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME;
/**
* Cloudinary API key.
*
* @example
* '123456789012345';
*
* @see https://cloudinary.com/documentation/cloudinary_references
* @see https://cloudinary.com/console
*/
export const CLOUDINARY_KEY = parsed.data.CLOUDINARY_KEY;
/**
* Cloudinary API secret.
*
* @example
* 'abcdefghijklmnopqrstuvwxyz123456';
*
* @see https://cloudinary.com/documentation/cloudinary_references
* @see https://cloudinary.com/console
*/
export const CLOUDINARY_SECRET = parsed.data.CLOUDINARY_SECRET;
/**
* Secret key for signing confirmation tokens.
*
* @example
* 'abcdefghijklmnopqrstuvwxyz123456';
*
* @see README.md for instructions on generating a secret key.
*/
export const CONFIRMATION_TOKEN_SECRET = parsed.data.CONFIRMATION_TOKEN_SECRET;
/**
* Secret key for signing reset password tokens.
*
* @example
* 'abcdefghijklmnopqrstuvwxyz123456';
*
* @see README.md for instructions on generating a secret key.
*/
export const RESET_PASSWORD_TOKEN_SECRET = parsed.data.RESET_PASSWORD_TOKEN_SECRET;
/**
* Secret key for signing session cookies.
*
* @example
* 'abcdefghijklmnopqrstuvwxyz123456';
*
* @see README.md for instructions on generating a secret key.
*/
export const SESSION_SECRET = parsed.data.SESSION_SECRET;
/**
* Name of the session cookie.
*
* @example
* 'my-app-session';
*/
export const SESSION_TOKEN_NAME = parsed.data.SESSION_TOKEN_NAME;
/**
* Maximum age of the session cookie in seconds.
*
* @example
* '86400000'; // 24 hours
*/
export const SESSION_MAX_AGE = parsed.data.SESSION_MAX_AGE;
/**
* PostgreSQL connection URL for Prisma taken from Neon.
*
* @example
* 'postgresql://user:password@host:5432/database';
*
* @see https://neon.tech/docs/guides/prisma
*/
export const POSTGRES_PRISMA_URL = parsed.data.POSTGRES_PRISMA_URL;
/**
* Non-pooling PostgreSQL connection URL taken from Neon.
*
* @example
* 'postgresql://user:password@host:5432/database';
*
* @see https://neon.tech/docs/guides/prisma
*/
export const POSTGRES_URL_NON_POOLING = parsed.data.POSTGRES_URL_NON_POOLING;
/**
* The URL of another Neon PostgreSQL database to shadow for migrations.
*
* @example
* 'postgresql://user:password@host:5432/database';
*
* @see https://neon.tech/docs/guides/prisma-migrate
*/
export const SHADOW_DATABASE_URL = parsed.data.SHADOW_DATABASE_URL;
/**
* Node environment.
*
* @example
* 'production';
*
* @see https://nodejs.org/api/process.html#process_process_env
*/
export const NODE_ENV = parsed.data.NODE_ENV;
/**
* SparkPost API key.
*
* @example
* 'abcdefghijklmnopqrstuvwxyz123456';
*
* @see https://app.sparkpost.com/account/api-keys
*/
export const SPARKPOST_API_KEY = parsed.data.SPARKPOST_API_KEY;
/**
* Sender email address for SparkPost.
*
* @example
* 'noreply@example.com';
*
* @see https://app.sparkpost.com/domains/list/sending
*/
export const SPARKPOST_SENDER_ADDRESS = parsed.data.SPARKPOST_SENDER_ADDRESS;
/**
* Your Mapbox access token.
*
* @example
* 'pk.abcdefghijklmnopqrstuvwxyz123456';
*
* @see https://docs.mapbox.com/help/how-mapbox-works/access-tokens/
*/
export const MAPBOX_ACCESS_TOKEN = parsed.data.MAPBOX_ACCESS_TOKEN;
/**
* Admin password for seeding the database.
*
* @example
* 'abcdefghijklmnopqrstuvwxyz123456';
*
* @see README.md for instructions on generating a secret key.
*/
export const ADMIN_PASSWORD = parsed.data.ADMIN_PASSWORD;

View File

@@ -1,58 +0,0 @@
import { BasicUserInfoSchema } from '@/config/auth/types';
import jwt, { JsonWebTokenError } from 'jsonwebtoken';
import { z } from 'zod';
import { CONFIRMATION_TOKEN_SECRET, RESET_PASSWORD_TOKEN_SECRET } from '../env';
import ServerError from '../util/ServerError';
export const generateConfirmationToken = (user: z.infer<typeof BasicUserInfoSchema>) => {
return jwt.sign(user, CONFIRMATION_TOKEN_SECRET, { expiresIn: '3m' });
};
export const verifyConfirmationToken = async (token: string) => {
try {
const decoded = jwt.verify(token, CONFIRMATION_TOKEN_SECRET);
const parsed = BasicUserInfoSchema.safeParse(decoded);
if (!parsed.success) {
throw new ServerError('Invalid token.', 401);
}
return parsed.data;
} catch (error) {
if (error instanceof Error && error.message === 'jwt expired') {
throw new ServerError(
'Your confirmation token is expired. Please generate a new one.',
401,
);
}
throw new ServerError('Something went wrong', 500);
}
};
export const generateResetPasswordToken = (user: z.infer<typeof BasicUserInfoSchema>) => {
return jwt.sign(user, RESET_PASSWORD_TOKEN_SECRET, { expiresIn: '5m' });
};
export const verifyResetPasswordToken = async (token: string) => {
try {
const decoded = jwt.verify(token, RESET_PASSWORD_TOKEN_SECRET);
const parsed = BasicUserInfoSchema.safeParse(decoded);
if (!parsed.success) {
throw new Error('Invalid token');
}
return parsed.data;
} catch (error) {
if (error instanceof JsonWebTokenError) {
throw new ServerError(
'Your reset password token is invalid. Please generate a new one.',
401,
);
}
throw new ServerError('Something went wrong', 500);
}
};

View File

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

View File

@@ -1,28 +0,0 @@
import multer from 'multer';
import { expressWrapper } from 'next-connect';
import { storage } from '../cloudinary';
const fileFilter: multer.Options['fileFilter'] = (req, file, callback) => {
const { mimetype } = file;
const isImage = mimetype.startsWith('image/');
if (!isImage) {
callback(null, false);
}
callback(null, true);
};
export const uploadMiddlewareMultiple = expressWrapper(
multer({ storage, fileFilter, limits: { files: 5, fileSize: 15 * 1024 * 1024 } }).array(
'images',
),
);
export const singleUploadMiddleware = expressWrapper(
multer({
storage,
fileFilter,
limits: { files: 1, fileSize: 15 * 1024 * 1024 },
}).single('image'),
);

View File

@@ -1,41 +0,0 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { RequestHandler } from 'next-connect/dist/types/node';
import type { HandlerOptions } from 'next-connect/dist/types/types';
import { z } from 'zod';
import logger from '../pino/logger';
import ServerError from '../util/ServerError';
import { NODE_ENV } from '../env';
type NextConnectOptionsT = HandlerOptions<
RequestHandler<
NextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>
>;
const NextConnectOptions: NextConnectOptionsT = {
onNoMatch(req, res) {
res.status(405).json({
message: 'Method not allowed.',
statusCode: 405,
success: false,
});
},
onError(error, req, res) {
if (NODE_ENV !== 'production') {
logger.error(error);
}
const message = error instanceof Error ? error.message : 'Internal server error.';
const statusCode = error instanceof ServerError ? error.statusCode : 500;
res.status(statusCode).json({
message,
statusCode,
success: false,
});
},
};
export default NextConnectOptions;

View File

@@ -1,26 +0,0 @@
import { NextApiResponse } from 'next';
import { NextHandler } from 'next-connect';
import ServerError from '@/config/util/ServerError';
import { findUserByIdService } from '@/services/users/auth';
import { getLoginSession } from '@/config/auth/session';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
/** Get the current user from the session. Adds the user to the request object. */
const getCurrentUser = async (
req: UserExtendedNextApiRequest,
res: NextApiResponse,
next: NextHandler,
) => {
const session = await getLoginSession(req);
const user = await findUserByIdService({ userId: session?.id });
if (!user) {
throw new ServerError('User is not logged in.', 401);
}
req.user = user;
return next();
};
export default getCurrentUser;

View File

@@ -1,47 +0,0 @@
import ServerError from '@/config/util/ServerError';
import { NextApiRequest, NextApiResponse } from 'next';
import { NextHandler } from 'next-connect';
import { z } from 'zod';
interface ValidateRequestArgs {
bodySchema?: z.ZodSchema<any>;
querySchema?: z.ZodSchema<any>;
}
/**
* Middleware to validate the request body and/or query against a zod schema.
*
* @example
* const handler = nextConnect(NextConnectConfig).post(
* validateRequest({ bodySchema: BeerPostValidationSchema }),
* getCurrentUser,
* createBeerPost,
* );
*
* @param args
* @param args.bodySchema The body schema to validate against.
* @param args.querySchema The query schema to validate against.
* @throws ServerError with status code 400 if the request body or query is invalid.
*/
const validateRequest = ({ bodySchema, querySchema }: ValidateRequestArgs) => {
return (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => {
if (bodySchema) {
const parsed = bodySchema.safeParse(JSON.parse(JSON.stringify(req.body)));
if (!parsed.success) {
throw new ServerError('Invalid request body.', 400);
}
req.body = parsed.data;
}
if (querySchema) {
const parsed = querySchema.safeParse(req.query);
if (!parsed.success) {
throw new ServerError('Invalid request query.', 400);
}
req.query = parsed.data;
}
return next();
};
};
export default validateRequest;

View File

@@ -1,5 +0,0 @@
import pino from 'pino';
const logger = pino();
export default logger;

View File

@@ -1,35 +0,0 @@
import { SPARKPOST_API_KEY, SPARKPOST_SENDER_ADDRESS } from '../env';
interface EmailParams {
address: string;
text: string;
html: string;
subject: string;
}
const sendEmail = async ({ address, text, html, subject }: EmailParams) => {
const from = SPARKPOST_SENDER_ADDRESS;
const data = {
recipients: [{ address }],
content: { from, subject, text, html },
};
const transmissionsEndpoint = 'https://api.sparkpost.com/api/v1/transmissions';
const response = await fetch(transmissionsEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: SPARKPOST_API_KEY,
},
body: JSON.stringify(data),
});
if (response.status !== 200) {
throw new Error(`Sparkpost API returned status code ${response.status}`);
}
};
export default sendEmail;

View File

@@ -1,11 +0,0 @@
class ServerError extends Error {
constructor(
message: string,
public statusCode: number,
) {
super(message);
this.name = 'ServerError';
}
}
export default ServerError;

View File

@@ -1,24 +0,0 @@
import useUser from '@/hooks/auth/useUser';
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
import { ReactNode, createContext } from 'react';
import { z } from 'zod';
const UserContext = createContext<{
user?: z.infer<typeof GetUserSchema>;
error?: unknown;
isLoading: boolean;
mutate?: ReturnType<typeof useUser>['mutate'];
}>({ isLoading: true });
export default UserContext;
type AuthProviderComponent = (props: { children: ReactNode }) => JSX.Element;
export const AuthProvider: AuthProviderComponent = ({ children }) => {
const { error, isLoading, mutate, user } = useUser();
return (
<UserContext.Provider value={{ isLoading, error, mutate, user }}>
{children}
</UserContext.Provider>
);
};

View File

@@ -1,114 +0,0 @@
import { NextApiResponse } from 'next';
import { NextHandler } from 'next-connect';
import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import {
getBeerPostCommentByIdService,
editBeerPostCommentByIdService,
createBeerPostCommentService,
getAllBeerCommentsService,
deleteBeerCommentByIdService,
} from '@/services/comments/beer-comment';
import {
CommentRequest,
EditAndCreateCommentRequest,
GetAllCommentsRequest,
} from '../types';
export const checkIfBeerCommentOwner = async <T extends CommentRequest>(
req: T,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
next: NextHandler,
) => {
const { commentId } = req.query;
const user = req.user!;
const comment = await getBeerPostCommentByIdService({ beerPostCommentId: commentId });
if (!comment) {
throw new ServerError('Comment not found', 404);
}
if (comment.postedBy.id !== user.id) {
throw new ServerError('You are not authorized to modify this comment', 403);
}
return next();
};
export const editBeerPostComment = async (
req: EditAndCreateCommentRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { commentId } = req.query;
await editBeerPostCommentByIdService({ body: req.body, beerPostCommentId: commentId });
res.status(200).json({
success: true,
message: 'Comment updated successfully',
statusCode: 200,
});
};
export const deleteBeerPostComment = async (
req: CommentRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { commentId } = req.query;
await deleteBeerCommentByIdService({ beerPostCommentId: commentId });
res.status(200).json({
success: true,
message: 'Comment deleted successfully',
statusCode: 200,
});
};
export const createBeerPostComment = async (
req: EditAndCreateCommentRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const beerPostId = req.query.postId;
const newBeerComment = await createBeerPostCommentService({
body: req.body,
userId: req.user!.id,
beerPostId,
});
res.status(201).json({
message: 'Beer comment created successfully',
statusCode: 201,
payload: newBeerComment,
success: true,
});
};
export const getAllBeerPostComments = async (
req: GetAllCommentsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const beerPostId = req.query.postId;
// eslint-disable-next-line @typescript-eslint/naming-convention
const { page_size, page_num } = req.query;
const { comments, count } = await getAllBeerCommentsService({
beerPostId,
pageNum: parseInt(page_num, 10),
pageSize: parseInt(page_size, 10),
});
res.setHeader('X-Total-Count', count);
res.status(200).json({
message: 'Beer comments fetched successfully',
statusCode: 200,
payload: comments,
success: true,
});
};

View File

@@ -1,120 +0,0 @@
import ServerError from '@/config/util/ServerError';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { NextHandler } from 'next-connect';
import { z } from 'zod';
import {
updateBeerStyleCommentById,
createNewBeerStyleComment,
getAllBeerStyleComments,
findBeerStyleCommentById,
deleteBeerStyleCommentById,
} from '@/services/comments/beer-style-comment';
import {
CommentRequest,
EditAndCreateCommentRequest,
GetAllCommentsRequest,
} from '../types';
export const checkIfBeerStyleCommentOwner = async <
CommentRequestType extends CommentRequest,
>(
req: CommentRequestType,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
next: NextHandler,
) => {
const { commentId } = req.query;
const user = req.user!;
const beerStyleComment = await findBeerStyleCommentById({
beerStyleCommentId: commentId,
});
if (!beerStyleComment) {
throw new ServerError('Beer style comment not found.', 404);
}
if (beerStyleComment.postedBy.id !== user.id) {
throw new ServerError(
'You are not authorized to modify this beer style comment.',
403,
);
}
return next();
};
export const editBeerStyleComment = async (
req: EditAndCreateCommentRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
await updateBeerStyleCommentById({
beerStyleCommentId: req.query.commentId,
body: req.body,
});
return res.status(200).json({
success: true,
message: 'Comment updated successfully',
statusCode: 200,
});
};
export const deleteBeerStyleComment = async (
req: CommentRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { commentId } = req.query;
await deleteBeerStyleCommentById({ beerStyleCommentId: commentId });
res.status(200).json({
success: true,
message: 'Comment deleted successfully',
statusCode: 200,
});
};
export const createComment = async (
req: EditAndCreateCommentRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const newBeerStyleComment = await createNewBeerStyleComment({
body: req.body,
beerStyleId: req.query.postId,
userId: req.user!.id,
});
res.status(201).json({
message: 'Beer comment created successfully',
statusCode: 201,
payload: newBeerStyleComment,
success: true,
});
};
export const getAll = async (
req: GetAllCommentsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const beerStyleId = req.query.postId;
// eslint-disable-next-line @typescript-eslint/naming-convention
const { page_size, page_num } = req.query;
const { comments, count } = await getAllBeerStyleComments({
beerStyleId,
pageNum: parseInt(page_num, 10),
pageSize: parseInt(page_size, 10),
});
res.setHeader('X-Total-Count', count);
res.status(200).json({
message: 'Beer comments fetched successfully',
statusCode: 200,
payload: comments,
success: true,
});
};

View File

@@ -1,121 +0,0 @@
import ServerError from '@/config/util/ServerError';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { NextHandler } from 'next-connect';
import { z } from 'zod';
import {
getBreweryCommentById,
createNewBreweryComment,
getAllBreweryComments,
deleteBreweryCommentByIdService,
updateBreweryCommentById,
} from '@/services/comments/brewery-comment';
import {
CommentRequest,
EditAndCreateCommentRequest,
GetAllCommentsRequest,
} from '../types';
export const checkIfBreweryCommentOwner = async <
CommentRequestType extends CommentRequest,
>(
req: CommentRequestType,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
next: NextHandler,
) => {
const { commentId } = req.query;
const user = req.user!;
const comment = await getBreweryCommentById({ breweryCommentId: commentId });
if (!comment) {
throw new ServerError('Comment not found', 404);
}
if (comment.postedBy.id !== user.id) {
throw new ServerError('You are not authorized to modify this comment', 403);
}
return next();
};
export const editBreweryPostComment = async (
req: EditAndCreateCommentRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { commentId } = req.query;
const updated = await updateBreweryCommentById({
breweryCommentId: commentId,
body: req.body,
});
return res.status(200).json({
success: true,
message: 'Comment updated successfully',
statusCode: 200,
payload: updated,
});
};
export const deleteBreweryPostComment = async (
req: CommentRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { commentId } = req.query;
await deleteBreweryCommentByIdService({ breweryCommentId: commentId });
res.status(200).json({
success: true,
message: 'Brewery comment deleted successfully',
statusCode: 200,
});
};
export const createComment = async (
req: EditAndCreateCommentRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const breweryPostId = req.query.postId;
const user = req.user!;
const newBreweryComment = await createNewBreweryComment({
body: req.body,
breweryPostId,
userId: user.id,
});
res.status(201).json({
message: 'Brewery comment created successfully',
statusCode: 201,
payload: newBreweryComment,
success: true,
});
};
export const getAll = async (
req: GetAllCommentsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const breweryPostId = req.query.postId;
// eslint-disable-next-line @typescript-eslint/naming-convention
const { page_size, page_num } = req.query;
const { comments, count } = await getAllBreweryComments({
id: breweryPostId,
pageNum: parseInt(page_num, 10),
pageSize: parseInt(page_size, 10),
});
res.setHeader('X-Total-Count', count);
res.status(200).json({
message: 'Brewery comments fetched successfully',
statusCode: 200,
payload: comments,
success: true,
});
};

View File

@@ -1,15 +0,0 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
import { z } from 'zod';
export interface CommentRequest extends UserExtendedNextApiRequest {
query: { postId: string; commentId: string };
}
export interface EditAndCreateCommentRequest extends CommentRequest {
body: z.infer<typeof CreateCommentValidationSchema>;
}
export interface GetAllCommentsRequest extends UserExtendedNextApiRequest {
query: { postId: string; page_size: string; page_num: string };
}

View File

@@ -1,51 +0,0 @@
import ServerError from '@/config/util/ServerError';
import {
addBeerImagesService,
deleteBeerImageService,
} from '@/services/images/beer-image';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import { DeleteImageRequest, UploadImagesRequest } from '../types';
// eslint-disable-next-line import/prefer-default-export
export const processBeerImageData = async (
req: UploadImagesRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { files, user, body } = req;
if (!files || !files.length) {
throw new ServerError('No images uploaded', 400);
}
const beerImages = await addBeerImagesService({
beerPostId: req.query.postId,
userId: user!.id,
body,
files,
});
res.status(200).json({
success: true,
message: `Successfully uploaded ${beerImages.length} image${
beerImages.length > 1 ? 's' : ''
}`,
statusCode: 200,
});
};
export const deleteBeerImageData = async (
req: DeleteImageRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { id } = req.query;
await deleteBeerImageService({ beerImageId: id });
res.status(200).json({
success: true,
message: `Successfully deleted image with id ${id}`,
statusCode: 200,
});
};

View File

@@ -1,34 +0,0 @@
import ServerError from '@/config/util/ServerError';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import { addBreweryImagesService } from '@/services/images/brewery-image';
import { UploadImagesRequest } from '../types';
// eslint-disable-next-line import/prefer-default-export
export const processBreweryImageData = async (
req: UploadImagesRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { files, user, body } = req;
if (!files || !files.length) {
throw new ServerError('No images uploaded', 400);
}
const breweryImages = await addBreweryImagesService({
breweryPostId: req.query.postId,
userId: user!.id,
body,
files,
});
res.status(200).json({
success: true,
message: `Successfully uploaded ${breweryImages.length} image${
breweryImages.length > 1 ? 's' : ''
}`,
statusCode: 200,
});
};

View File

@@ -1,13 +0,0 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import ImageMetadataValidationSchema from '@/services/schema/ImageSchema/ImageMetadataValidationSchema';
import { z } from 'zod';
export interface UploadImagesRequest extends UserExtendedNextApiRequest {
files?: Express.Multer.File[];
query: { postId: string };
body: z.infer<typeof ImageMetadataValidationSchema>;
}
export interface DeleteImageRequest extends UserExtendedNextApiRequest {
query: { id: string };
}

View File

@@ -1,90 +0,0 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import ServerError from '@/config/util/ServerError';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import { getBeerPostById } from '@/services/posts/beer-post';
import {
findBeerPostLikeByIdService,
createBeerPostLikeService,
removeBeerPostLikeService,
getBeerPostLikeCountService,
} from '@/services/likes/beer-post-like';
import { LikeRequest } from '../types';
export const sendBeerPostLikeRequest = async (
req: LikeRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const user = req.user!;
const { postId } = req.query;
const beer = await getBeerPostById({ beerPostId: postId });
if (!beer) {
throw new ServerError('Could not find a beer post with that id.', 404);
}
const liked = await findBeerPostLikeByIdService({
beerPostId: beer.id,
likedById: user.id,
});
if (liked) {
await removeBeerPostLikeService({ beerPostLikeId: liked.id });
res.status(200).json({
success: true,
message: 'Successfully unliked beer post.',
statusCode: 200,
payload: { liked: false },
});
return;
}
await createBeerPostLikeService({ beerPostId: beer.id, likedById: user.id });
res.status(200).json({
success: true,
message: 'Successfully liked beer post.',
statusCode: 200,
payload: { liked: true },
});
};
export const getBeerPostLikeCount = async (
req: LikeRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { postId } = req.query;
const likeCount = await getBeerPostLikeCountService({ beerPostId: postId });
res.status(200).json({
success: true,
message: 'Successfully retrieved like count.',
statusCode: 200,
payload: { likeCount },
});
};
export const checkIfBeerPostIsLiked = async (
req: UserExtendedNextApiRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const user = req.user!;
const beerPostId = req.query.postId as string;
const alreadyLiked = await findBeerPostLikeByIdService({
beerPostId,
likedById: user.id,
});
res.status(200).json({
success: true,
message: alreadyLiked ? 'Beer post is liked.' : 'Beer post is not liked.',
statusCode: 200,
payload: { isLiked: !!alreadyLiked },
});
};

View File

@@ -1,82 +0,0 @@
import ServerError from '@/config/util/ServerError';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import {
createBeerStyleLikeService,
findBeerStyleLikeService,
getBeerStyleLikeCountService,
removeBeerStyleLikeService,
} from '@/services/likes/beer-style-like';
import { getBeerStyleByIdService } from '@/services/posts/beer-style-post';
import { LikeRequest } from '../types';
export const sendBeerStyleLikeRequest = async (
req: LikeRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const user = req.user!;
const { postId } = req.query;
const beerStyle = await getBeerStyleByIdService({ beerStyleId: postId });
if (!beerStyle) {
throw new ServerError('Could not find a beer style with that id.', 404);
}
const beerStyleLike = await findBeerStyleLikeService({
beerStyleId: beerStyle.id,
likedById: user.id,
});
if (beerStyleLike) {
await removeBeerStyleLikeService({ beerStyleLikeId: beerStyleLike.id });
res.status(200).json({
message: 'Successfully unliked beer style.',
success: true,
statusCode: 200,
});
} else {
await createBeerStyleLikeService({ beerStyleId: beerStyle.id, likedById: user.id });
res.status(200).json({
message: 'Successfully liked beer style.',
success: true,
statusCode: 200,
});
}
};
export const getBeerStyleLikeCountRequest = async (
req: LikeRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { postId } = req.query;
const likeCount = await getBeerStyleLikeCountService({ beerStyleId: postId });
res.status(200).json({
success: true,
message: 'Successfully retrieved like count.',
statusCode: 200,
payload: { likeCount },
});
};
export const checkIfBeerStyleIsLiked = async (
req: UserExtendedNextApiRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const user = req.user!;
const beerStyleId = req.query.id as string;
const alreadyLiked = await findBeerStyleLikeService({
beerStyleId,
likedById: user.id,
});
res.status(200).json({
success: true,
message: alreadyLiked ? 'Beer style is liked.' : 'Beer style is not liked.',
statusCode: 200,
payload: { isLiked: !!alreadyLiked },
});
};

View File

@@ -1,96 +0,0 @@
import ServerError from '@/config/util/ServerError';
import {
createBreweryPostLikeService,
findBreweryPostLikeService,
getBreweryPostLikeCountService,
removeBreweryPostLikeService,
} from '@/services/likes/brewery-post-like';
import { getBreweryPostByIdService } from '@/services/posts/brewery-post';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import { LikeRequest } from '../types';
export const sendBreweryPostLikeRequest = async (
req: LikeRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { postId } = req.query;
const user = req.user!;
const breweryPost = await getBreweryPostByIdService({ breweryPostId: postId });
if (!breweryPost) {
throw new ServerError('Could not find a brewery post with that id', 404);
}
const like = await findBreweryPostLikeService({
breweryPostId: breweryPost.id,
likedById: user.id,
});
if (like) {
await removeBreweryPostLikeService({ breweryPostLikeId: like.id });
res.status(200).json({
success: true,
message: 'Successfully removed like from brewery post.',
statusCode: 200,
});
return;
}
await createBreweryPostLikeService({
breweryPostId: breweryPost.id,
likedById: user.id,
});
res.status(200).json({
success: true,
message: 'Successfully liked brewery post.',
statusCode: 200,
});
};
export const getBreweryPostLikeCount = async (
req: LikeRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { postId } = req.query;
const breweryPost = await getBreweryPostByIdService({ breweryPostId: postId });
if (!breweryPost) {
throw new ServerError('Could not find a brewery post with that id', 404);
}
const likeCount = await getBreweryPostLikeCountService({
breweryPostId: breweryPost.id,
});
res.status(200).json({
success: true,
message: 'Successfully retrieved like count',
statusCode: 200,
payload: { likeCount },
});
};
export const getBreweryPostLikeStatus = async (
req: LikeRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const user = req.user!;
const { postId } = req.query;
const liked = await findBreweryPostLikeService({
breweryPostId: postId,
likedById: user.id,
});
res.status(200).json({
success: true,
message: liked ? 'Brewery post is liked.' : 'Brewery post is not liked.',
statusCode: 200,
payload: { isLiked: !!liked },
});
};

View File

@@ -1,5 +0,0 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
export interface LikeRequest extends UserExtendedNextApiRequest {
query: { postId: string };
}

View File

@@ -1,170 +0,0 @@
import ServerError from '@/config/util/ServerError';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { NextHandler } from 'next-connect';
import { z } from 'zod';
import { GetAllPostsByConnectedPostId } from '@/controllers/posts/types';
import {
BeerPostRequest,
CreateBeerPostRequest,
EditBeerPostRequest,
GetAllBeerPostsRequest,
GetBeerRecommendationsRequest,
} from '@/controllers/posts/beer-posts/types';
import {
getBeerPostById,
editBeerPostByIdService,
deleteBeerPostByIdService,
getBeerRecommendationsService,
getAllBeerPostsService,
createNewBeerPost,
getBeerPostsByPostedByIdService,
} from '@/services/posts/beer-post';
export const checkIfBeerPostOwner = async <BeerPostRequestType extends BeerPostRequest>(
req: BeerPostRequestType,
res: NextApiResponse,
next: NextHandler,
) => {
const { user, query } = req;
const { postId } = query;
const beerPost = await getBeerPostById({ beerPostId: postId });
if (!beerPost) {
throw new ServerError('Beer post not found', 404);
}
if (beerPost.postedBy.id !== user!.id) {
throw new ServerError('You cannot edit that beer post.', 403);
}
return next();
};
export const editBeerPost = async (
req: EditBeerPostRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
await editBeerPostByIdService({ beerPostId: req.query.postId, body: req.body });
res.status(200).json({
message: 'Beer post updated successfully',
success: true,
statusCode: 200,
});
};
export const deleteBeerPost = async (req: BeerPostRequest, res: NextApiResponse) => {
const { postId } = req.query;
const deleted = await deleteBeerPostByIdService({ beerPostId: postId });
if (!deleted) {
throw new ServerError('Beer post not found', 404);
}
res.status(200).json({
message: 'Beer post deleted successfully',
success: true,
statusCode: 200,
});
};
export const getBeerPostRecommendations = async (
req: GetBeerRecommendationsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { postId } = req.query;
const beerPost = await getBeerPostById({ beerPostId: postId });
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 { beerRecommendations, count } = await getBeerRecommendationsService({
beerPost,
pageNum,
pageSize,
});
res.setHeader('X-Total-Count', count);
res.status(200).json({
success: true,
message: 'Recommendations fetched successfully',
statusCode: 200,
payload: beerRecommendations,
});
};
export const getBeerPosts = async (
req: GetAllBeerPostsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const pageNum = parseInt(req.query.page_num, 10);
const pageSize = parseInt(req.query.page_size, 10);
const { beerPosts, count } = await getAllBeerPostsService({ pageNum, pageSize });
res.setHeader('X-Total-Count', count);
res.status(200).json({
message: 'Beer posts retrieved successfully',
statusCode: 200,
payload: beerPosts,
success: true,
});
};
export const createBeerPost = async (
req: CreateBeerPostRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { name, description, styleId, abv, ibu, breweryId } = req.body;
const newBeerPost = await createNewBeerPost({
name,
description,
abv,
ibu,
styleId,
breweryId,
userId: req.user!.id,
});
res.status(201).json({
message: 'Beer post created successfully',
statusCode: 201,
payload: newBeerPost,
success: true,
});
};
export const getBeerPostsByUserId = async (
req: GetAllPostsByConnectedPostId,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const pageNum = parseInt(req.query.page_num, 10);
const pageSize = parseInt(req.query.page_size, 10);
const { id } = req.query;
const { beerPosts, count } = await getBeerPostsByPostedByIdService({
pageNum,
pageSize,
postedById: id,
});
res.setHeader('X-Total-Count', count);
res.status(200).json({
message: `Beer posts by user ${id} fetched successfully`,
statusCode: 200,
payload: beerPosts,
success: true,
});
};

View File

@@ -1,25 +0,0 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import CreateBeerPostValidationSchema from '@/services/posts/beer-post/schema/CreateBeerPostValidationSchema';
import EditBeerPostValidationSchema from '@/services/posts/beer-post/schema/EditBeerPostValidationSchema';
import { NextApiRequest } from 'next';
import { z } from 'zod';
export interface BeerPostRequest extends UserExtendedNextApiRequest {
query: { postId: string };
}
export interface EditBeerPostRequest extends BeerPostRequest {
body: z.infer<typeof EditBeerPostValidationSchema>;
}
export interface GetAllBeerPostsRequest extends NextApiRequest {
query: { page_num: string; page_size: string };
}
export interface GetBeerRecommendationsRequest extends BeerPostRequest {
query: { postId: string; page_num: string; page_size: string };
}
export interface CreateBeerPostRequest extends UserExtendedNextApiRequest {
body: z.infer<typeof CreateBeerPostValidationSchema>;
}

View File

@@ -1,105 +0,0 @@
import { NextApiResponse } from 'next';
import { z } from 'zod';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { getBeerPostsByBeerStyleIdService } from '@/services/posts/beer-post';
import {
createBeerStyleService,
getAllBeerStylesService,
getBeerStyleByIdService,
} from '@/services/posts/beer-style-post';
import { CreateBeerStyleRequest, GetBeerStyleByIdRequest } from './types';
import { GetAllPostsByConnectedPostId, GetAllPostsRequest } from '../types';
export const getBeerStyle = async (
req: GetBeerStyleByIdRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { id } = req.query;
const beerStyle = await getBeerStyleByIdService({
beerStyleId: id,
});
res.status(200).json({
message: 'Beer style retrieved successfully.',
statusCode: 200,
payload: beerStyle,
success: true,
});
};
export const getAllBeersByBeerStyle = async (
req: GetAllPostsByConnectedPostId,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { page_size, page_num, id } = req.query;
const { beerPosts, count } = await getBeerPostsByBeerStyleIdService({
pageNum: parseInt(page_num, 10),
pageSize: parseInt(page_size, 10),
styleId: id,
});
res.setHeader('X-Total-Count', count);
res.status(200).json({
message: `Beers with style id ${id} retrieved successfully.`,
statusCode: 200,
payload: beerPosts,
success: true,
});
};
export const getBeerStyles = async (
req: GetAllPostsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const pageNum = parseInt(req.query.page_num, 10);
const pageSize = parseInt(req.query.page_size, 10);
const { beerStyles, beerStyleCount } = await getAllBeerStylesService({
pageNum,
pageSize,
});
res.setHeader('X-Total-Count', beerStyleCount);
res.status(200).json({
message: 'Beer styles retrieved successfully.',
statusCode: 200,
payload: beerStyles,
success: true,
});
};
export const createBeerStyle = async (
req: CreateBeerStyleRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { abvRange, description, glasswareId, ibuRange, name } = req.body;
const user = req.user!;
const beerStyle = await createBeerStyleService({
glasswareId,
postedById: user.id,
body: {
abvRange,
description,
ibuRange,
name,
},
});
res.json({
message: 'Beer style created successfully.',
statusCode: 200,
payload: beerStyle,
success: true,
});
};

View File

@@ -1,13 +0,0 @@
import { NextApiRequest } from 'next';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import { z } from 'zod';
import CreateBeerStyleValidationSchema from '@/services/posts/beer-style-post/schema/CreateBeerStyleValidationSchema';
export interface GetBeerStyleByIdRequest extends NextApiRequest {
query: { id: string };
}
export interface CreateBeerStyleRequest extends UserExtendedNextApiRequest {
body: z.infer<typeof CreateBeerStyleValidationSchema>;
}

View File

@@ -1,218 +0,0 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import geocode from '@/config/mapbox/geocoder';
import ServerError from '@/config/util/ServerError';
import {
getAllBreweryPostsByPostedByIdService,
getAllBreweryPostsService,
createNewBreweryPostService,
createBreweryPostLocationService,
getMapBreweryPostsService,
getBreweryPostByIdService,
updateBreweryPostService,
deleteBreweryPostService,
} from '@/services/posts/brewery-post';
import { getBeerPostsByBreweryIdService } from '@/services/posts/beer-post';
import { NextHandler } from 'next-connect';
import {
BreweryPostRequest,
CreateBreweryPostRequest,
EditBreweryPostRequest,
GetBreweryPostsRequest,
} from './types';
import { GetAllPostsByConnectedPostId } from '../types';
export const getBreweryPostsByUserId = async (
req: GetAllPostsByConnectedPostId,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const pageNum = parseInt(req.query.page_num, 10);
const pageSize = parseInt(req.query.page_size, 10);
const { id } = req.query;
const { breweryPosts, count } = await getAllBreweryPostsByPostedByIdService({
pageNum,
pageSize,
postedById: id,
});
res.setHeader('X-Total-Count', count);
res.status(200).json({
message: `Brewery posts by user ${id} fetched successfully`,
statusCode: 200,
payload: breweryPosts,
success: true,
});
};
export const getBreweryPosts = async (
req: GetBreweryPostsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const pageNum = parseInt(req.query.page_num, 10);
const pageSize = parseInt(req.query.page_size, 10);
const { breweryPosts, count } = await getAllBreweryPostsService({ pageNum, pageSize });
res.setHeader('X-Total-Count', count);
res.status(200).json({
message: 'Brewery posts retrieved successfully',
statusCode: 200,
payload: breweryPosts,
success: true,
});
};
export const createBreweryPost = async (
req: CreateBreweryPostRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { name, description, dateEstablished, address, city, country, region } = req.body;
const userId = req.user!.id;
const fullAddress = `${address}, ${city}, ${region}, ${country}`;
const geocoded = await geocode(fullAddress);
if (!geocoded) {
throw new ServerError('Address is not valid', 400);
}
const [latitude, longitude] = geocoded.center;
const location = await createBreweryPostLocationService({
body: {
address,
city,
country,
stateOrProvince: region,
coordinates: [latitude, longitude],
},
postedById: userId,
});
const newBreweryPost = await createNewBreweryPostService({
name,
description,
locationId: location.id,
dateEstablished,
userId,
});
res.status(201).json({
message: 'Brewery post created successfully',
statusCode: 201,
payload: newBreweryPost,
success: true,
});
};
export const getMapBreweryPosts = async (
req: GetBreweryPostsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const pageNum = parseInt(req.query.page_num, 10);
const pageSize = parseInt(req.query.page_size, 10);
const { breweryPosts, count } = await getMapBreweryPostsService({
pageNum,
pageSize,
});
res.setHeader('X-Total-Count', count);
res.status(200).json({
message: 'Brewery posts retrieved successfully',
statusCode: 200,
payload: breweryPosts,
success: true,
});
};
export const getAllBeersByBrewery = async (
req: GetAllPostsByConnectedPostId,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { page_size, page_num, id } = req.query;
const pageNum = parseInt(page_num, 10);
const pageSize = parseInt(page_size, 10);
const { beerPosts, count } = await getBeerPostsByBreweryIdService({
pageNum,
pageSize,
breweryId: id,
});
res.setHeader('X-Total-Count', count);
res.status(200).json({
message: 'Beers fetched successfully',
statusCode: 200,
payload: beerPosts,
success: true,
});
};
export const checkIfBreweryPostOwner = async (
req: BreweryPostRequest,
res: NextApiResponse,
next: NextHandler,
) => {
const user = req.user!;
const { postId } = req.query;
const breweryPost = await getBreweryPostByIdService({ breweryPostId: postId });
if (!breweryPost) {
throw new ServerError('Brewery post not found', 404);
}
if (breweryPost.postedBy.id !== user.id) {
throw new ServerError('You are not the owner of this brewery post', 403);
}
return next();
};
export const editBreweryPost = async (
req: EditBreweryPostRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const {
body,
query: { postId },
} = req;
await updateBreweryPostService({ breweryPostId: postId, body });
res.status(200).json({
message: 'Brewery post updated successfully',
success: true,
statusCode: 200,
});
};
export const deleteBreweryPost = async (
req: BreweryPostRequest,
res: NextApiResponse,
) => {
const { postId } = req.query;
const deleted = await deleteBreweryPostService({ breweryPostId: postId });
if (!deleted) {
throw new ServerError('Brewery post not found', 404);
}
res.status(200).json({
message: 'Brewery post deleted successfully',
success: true,
statusCode: 200,
});
};

Some files were not shown because too many files have changed in this diff Show More