mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 20:13:49 +00:00
Restructure codebase to use src directory
This commit is contained in:
104
src/components/BeerById/BeerCommentForm.tsx
Normal file
104
src/components/BeerById/BeerCommentForm.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import sendCreateBeerCommentRequest from '@/requests/sendCreateBeerCommentRequest';
|
||||
import BeerCommentValidationSchema from '@/services/BeerComment/schema/CreateBeerCommentValidationSchema';
|
||||
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import { FunctionComponent, useState, useEffect } from 'react';
|
||||
import { Rating } from 'react-daisyui';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import useBeerPostComments from '@/hooks/useBeerPostComments';
|
||||
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';
|
||||
|
||||
interface BeerCommentFormProps {
|
||||
beerPost: z.infer<typeof beerPostQueryResult>;
|
||||
mutate: ReturnType<typeof useBeerPostComments>['mutate'];
|
||||
}
|
||||
|
||||
const BeerCommentForm: FunctionComponent<BeerCommentFormProps> = ({
|
||||
beerPost,
|
||||
mutate,
|
||||
}) => {
|
||||
const { register, handleSubmit, formState, reset, setValue } = useForm<
|
||||
z.infer<typeof BeerCommentValidationSchema>
|
||||
>({
|
||||
defaultValues: {
|
||||
rating: 0,
|
||||
},
|
||||
resolver: zodResolver(BeerCommentValidationSchema),
|
||||
});
|
||||
|
||||
const [rating, setRating] = useState(0);
|
||||
useEffect(() => {
|
||||
setRating(0);
|
||||
reset({ rating: 0, content: '' });
|
||||
}, [reset]);
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof BeerCommentValidationSchema>> = async (
|
||||
data,
|
||||
) => {
|
||||
setValue('rating', 0);
|
||||
setRating(0);
|
||||
await sendCreateBeerCommentRequest({
|
||||
content: data.content,
|
||||
rating: data.rating,
|
||||
beerPostId: beerPost.id,
|
||||
});
|
||||
await mutate();
|
||||
reset();
|
||||
};
|
||||
|
||||
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={rating}
|
||||
onChange={(value) => {
|
||||
setRating(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 BeerCommentForm;
|
||||
100
src/components/BeerById/BeerInfoHeader.tsx
Normal file
100
src/components/BeerById/BeerInfoHeader.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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/BeerPost/schema/BeerPostQueryResult';
|
||||
import { z } from 'zod';
|
||||
import useGetLikeCount from '@/hooks/useGetLikeCount';
|
||||
import useTimeDistance from '@/hooks/useTimeDistance';
|
||||
import BeerPostLikeButton from './BeerPostLikeButton';
|
||||
|
||||
const BeerInfoHeader: FC<{
|
||||
beerPost: z.infer<typeof beerPostQueryResult>;
|
||||
}> = ({ 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 } = useGetLikeCount(beerPost.id);
|
||||
|
||||
return (
|
||||
<main className="card flex flex-col justify-center bg-base-300">
|
||||
<article className="card-body">
|
||||
<div className="flex justify-between">
|
||||
<header>
|
||||
<h1 className="text-4xl font-bold">{beerPost.name}</h1>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
by{' '}
|
||||
<Link
|
||||
href={`/breweries/${beerPost.brewery.id}`}
|
||||
className="link-hover link text-2xl font-semibold"
|
||||
>
|
||||
{beerPost.brewery.name}
|
||||
</Link>
|
||||
</h2>
|
||||
</header>
|
||||
{isPostOwner && (
|
||||
<div className="tooltip tooltip-left" data-tip={`Edit '${beerPost.name}'`}>
|
||||
<Link
|
||||
href={`/beers/${beerPost.id}/edit`}
|
||||
className="btn-outline btn-sm btn"
|
||||
>
|
||||
<FaRegEdit className="text-xl" />
|
||||
</Link>
|
||||
</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-right"
|
||||
data-tip={format(createdAt, 'MM/dd/yyyy')}
|
||||
>
|
||||
{`${timeDistance} ago`}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<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/types/${beerPost.type.id}`}
|
||||
>
|
||||
{beerPost.type.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mr-4 text-lg font-medium">{beerPost.abv}% ABV</span>
|
||||
<span className="text-lg font-medium">{beerPost.ibu} IBU</span>
|
||||
</div>
|
||||
<div>
|
||||
{(!!likeCount || likeCount === 0) && (
|
||||
<span>
|
||||
Liked by {likeCount} user{likeCount !== 1 && 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions items-end">
|
||||
{user && <BeerPostLikeButton beerPostId={beerPost.id} mutateCount={mutate} />}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerInfoHeader;
|
||||
140
src/components/BeerById/BeerPostCommentsSection.tsx
Normal file
140
src/components/BeerById/BeerPostCommentsSection.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import UserContext from '@/contexts/userContext';
|
||||
|
||||
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
|
||||
|
||||
import { FC, MutableRefObject, useContext, useRef } from 'react';
|
||||
import { z } from 'zod';
|
||||
import useBeerPostComments from '@/hooks/useBeerPostComments';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { FaArrowUp } from 'react-icons/fa';
|
||||
import BeerCommentForm from './BeerCommentForm';
|
||||
|
||||
import CommentCardBody from './CommentCardBody';
|
||||
import NoCommentsCard from './NoCommentsCard';
|
||||
import LoadingComponent from './LoadingComponent';
|
||||
|
||||
interface BeerPostCommentsSectionProps {
|
||||
beerPost: z.infer<typeof beerPostQueryResult>;
|
||||
}
|
||||
|
||||
const BeerPostCommentsSection: FC<BeerPostCommentsSectionProps> = ({ beerPost }) => {
|
||||
const { user } = useContext(UserContext);
|
||||
const router = useRouter();
|
||||
const { id } = beerPost;
|
||||
const pageNum = parseInt(router.query.comments_page as string, 10) || 1;
|
||||
const PAGE_SIZE = 4;
|
||||
|
||||
const { comments, isLoading, mutate, setSize, size, isLoadingMore, isAtEnd } =
|
||||
useBeerPostComments({
|
||||
id,
|
||||
pageNum,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const { ref: lastCommentRef } = useInView({
|
||||
/**
|
||||
* When the last comment comes into view, call setSize from useBeerPostComments to
|
||||
* load more comments.
|
||||
*/
|
||||
onChange: (visible) => {
|
||||
if (!visible || isAtEnd) return;
|
||||
setSize(size + 1);
|
||||
},
|
||||
});
|
||||
|
||||
const sectionRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
<div className="card h-96 bg-base-300">
|
||||
<div className="card-body h-full" ref={sectionRef}>
|
||||
{user ? (
|
||||
<BeerCommentForm beerPost={beerPost} mutate={mutate} />
|
||||
) : (
|
||||
<div className="flex h-full 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">
|
||||
<LoadingComponent length={PAGE_SIZE} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!!comments.length && (
|
||||
<div className="card 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 ? lastCommentRef : undefined}
|
||||
key={comment.id}
|
||||
>
|
||||
<CommentCardBody comment={comment} mutate={mutate} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{
|
||||
/**
|
||||
* If there are more comments to load, show a loading component with a
|
||||
* skeleton loader and a loading spinner.
|
||||
*/
|
||||
!!isLoadingMore && (
|
||||
<LoadingComponent length={Math.floor(PAGE_SIZE / 2)} />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
/**
|
||||
* 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-ghost btn-sm btn"
|
||||
aria-label="Scroll back to top of comments"
|
||||
onClick={() => {
|
||||
sectionRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaArrowUp />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!comments.length && <NoCommentsCard />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerPostCommentsSection;
|
||||
57
src/components/BeerById/BeerPostLikeButton.tsx
Normal file
57
src/components/BeerById/BeerPostLikeButton.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import useCheckIfUserLikesBeerPost from '@/hooks/useCheckIfUserLikesBeerPost';
|
||||
import sendLikeRequest from '@/requests/sendLikeRequest';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaThumbsUp, FaRegThumbsUp } from 'react-icons/fa';
|
||||
|
||||
import useGetLikeCount from '@/hooks/useGetLikeCount';
|
||||
|
||||
const BeerPostLikeButton: FC<{
|
||||
beerPostId: string;
|
||||
mutateCount: ReturnType<typeof useGetLikeCount>['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 sendLikeRequest(beerPostId);
|
||||
await mutateCount();
|
||||
await mutateLikeStatus();
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn gap-2 rounded-2xl ${
|
||||
!isLiked ? 'btn-ghost outline' : 'btn-primary'
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleLike();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{isLiked ? (
|
||||
<>
|
||||
<FaThumbsUp className="text-2xl" />
|
||||
Liked
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaRegThumbsUp className="text-2xl" />
|
||||
Like
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerPostLikeButton;
|
||||
34
src/components/BeerById/BeerRecommendations.tsx
Normal file
34
src/components/BeerById/BeerRecommendations.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import BeerRecommendationQueryResult from '@/services/BeerPost/schema/BeerRecommendationQueryResult';
|
||||
import Link from 'next/link';
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
interface BeerRecommendationsProps {
|
||||
beerRecommendations: BeerRecommendationQueryResult[];
|
||||
}
|
||||
const BeerRecommendations: FunctionComponent<BeerRecommendationsProps> = ({
|
||||
beerRecommendations,
|
||||
}) => {
|
||||
return (
|
||||
<div className="card sticky top-2 h-full overflow-y-scroll bg-base-300">
|
||||
<div className="card-body space-y-3">
|
||||
{beerRecommendations.map((beerPost) => (
|
||||
<div key={beerPost.id} className="w-full">
|
||||
<div>
|
||||
<Link className="link-hover" href={`/beers/${beerPost.id}`} scroll={false}>
|
||||
<h2 className="text-2xl font-bold">{beerPost.name}</h2>
|
||||
</Link>
|
||||
<Link href={`/breweries/${beerPost.brewery.id}`} className="link-hover">
|
||||
<p className="text-lg font-semibold">{beerPost.brewery.name}</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p>{beerPost.abv}% ABV</p>
|
||||
<p>{beerPost.ibu} IBU</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerRecommendations;
|
||||
105
src/components/BeerById/CommentCardBody.tsx
Normal file
105
src/components/BeerById/CommentCardBody.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import UserContext from '@/contexts/userContext';
|
||||
import useBeerPostComments from '@/hooks/useBeerPostComments';
|
||||
import useTimeDistance from '@/hooks/useTimeDistance';
|
||||
import BeerCommentQueryResult from '@/services/BeerComment/schema/BeerCommentQueryResult';
|
||||
import format from 'date-fns/format';
|
||||
import Link from 'next/link';
|
||||
import { FC, useContext } from 'react';
|
||||
import { Rating } from 'react-daisyui';
|
||||
|
||||
import { FaEllipsisH } from 'react-icons/fa';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface CommentCardProps {
|
||||
comment: z.infer<typeof BeerCommentQueryResult>;
|
||||
mutate: ReturnType<typeof useBeerPostComments>['mutate'];
|
||||
ref?: ReturnType<typeof useInView>['ref'];
|
||||
}
|
||||
|
||||
const CommentCardDropdown: FC<CommentCardProps> = ({ comment, mutate }) => {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const isCommentOwner = user?.id === comment.postedBy.id;
|
||||
|
||||
const handleDelete = async () => {
|
||||
const response = await fetch(`/api/beer-comments/${comment.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete comment');
|
||||
}
|
||||
|
||||
await mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dropdown">
|
||||
<label tabIndex={0} className="btn-ghost btn-sm btn m-1">
|
||||
<FaEllipsisH />
|
||||
</label>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content menu rounded-box w-52 bg-base-100 p-2 shadow"
|
||||
>
|
||||
<li>
|
||||
{isCommentOwner ? (
|
||||
<button onClick={handleDelete}>Delete</button>
|
||||
) : (
|
||||
<button>Report</button>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CommentCardBody: FC<CommentCardProps> = ({ comment, mutate, ref }) => {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const timeDistance = useTimeDistance(new Date(comment.createdAt));
|
||||
|
||||
return (
|
||||
<div className="card-body animate-in fade-in-10" ref={ref}>
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<h3 className="font-semibold sm:text-2xl">
|
||||
<Link href={`/users/${comment.postedBy.id}`} className="link-hover link">
|
||||
{comment.postedBy.username}
|
||||
</Link>
|
||||
</h3>
|
||||
<h4 className="italic">
|
||||
posted{' '}
|
||||
<time
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip={format(new Date(comment.createdAt), 'MM/dd/yyyy')}
|
||||
>
|
||||
{timeDistance}
|
||||
</time>{' '}
|
||||
ago
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{user && <CommentCardDropdown comment={comment} mutate={mutate} />}
|
||||
</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>
|
||||
<p>{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentCardBody;
|
||||
19
src/components/BeerById/CommentLoadingCardBody.tsx
Normal file
19
src/components/BeerById/CommentLoadingCardBody.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
const CommentLoadingCardBody = () => {
|
||||
return (
|
||||
<div className="animate 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;
|
||||
22
src/components/BeerById/LoadingComponent.tsx
Normal file
22
src/components/BeerById/LoadingComponent.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { FC } from 'react';
|
||||
import Spinner from '../ui/Spinner';
|
||||
import CommentLoadingCardBody from './CommentLoadingCardBody';
|
||||
|
||||
interface LoadingComponentProps {
|
||||
length: number;
|
||||
}
|
||||
|
||||
const LoadingComponent: FC<LoadingComponentProps> = ({ length }) => {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length }).map((_, i) => (
|
||||
<CommentLoadingCardBody key={i} />
|
||||
))}
|
||||
<div className="p-1">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingComponent;
|
||||
13
src/components/BeerById/NoCommentsCard.tsx
Normal file
13
src/components/BeerById/NoCommentsCard.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
33
src/components/BeerIndex/BeerCard.tsx
Normal file
33
src/components/BeerIndex/BeerCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Link from 'next/link';
|
||||
import { FC } from 'react';
|
||||
import Image from 'next/image';
|
||||
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
|
||||
import { z } from 'zod';
|
||||
|
||||
const BeerCard: FC<{ post: z.infer<typeof beerPostQueryResult> }> = ({ post }) => {
|
||||
return (
|
||||
<div className="card bg-base-300" key={post.id}>
|
||||
<figure className="card-image h-96">
|
||||
{post.beerImages.length > 0 && (
|
||||
<Image
|
||||
src={post.beerImages[0].path}
|
||||
alt={post.name}
|
||||
width="1029"
|
||||
height="110"
|
||||
/>
|
||||
)}
|
||||
</figure>
|
||||
|
||||
<div className="card-body space-y-3">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold">
|
||||
<Link href={`/beers/${post.id}`}>{post.name}</Link>
|
||||
</h2>
|
||||
<h3 className="text-xl font-semibold">{post.brewery.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerCard;
|
||||
32
src/components/BeerIndex/BeerIndexPaginationBar.tsx
Normal file
32
src/components/BeerIndex/BeerIndexPaginationBar.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Link from 'next/link';
|
||||
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa';
|
||||
import { FC } from 'react';
|
||||
|
||||
interface PaginationProps {
|
||||
pageNum: number;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
const BeerIndexPaginationBar: FC<PaginationProps> = ({ pageCount, pageNum }) => {
|
||||
return (
|
||||
<div className="btn-group">
|
||||
<Link
|
||||
className={`btn ${pageNum === 1 ? 'btn-disabled' : ''}`}
|
||||
href={{ pathname: '/beers', query: { page_num: pageNum - 1 } }}
|
||||
scroll={false}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</Link>
|
||||
<button className="btn">Page {pageNum}</button>
|
||||
<Link
|
||||
className={`btn ${pageNum === pageCount ? 'btn-disabled' : ''}`}
|
||||
href={{ pathname: '/beers', query: { page_num: pageNum + 1 } }}
|
||||
scroll={false}
|
||||
>
|
||||
<FaArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerIndexPaginationBar;
|
||||
172
src/components/CreateBeerPostForm.tsx
Normal file
172
src/components/CreateBeerPostForm.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import sendCreateBeerPostRequest from '@/requests/sendCreateBeerPostRequest';
|
||||
import CreateBeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { BeerType } from '@prisma/client';
|
||||
import router from 'next/router';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
|
||||
import ErrorAlert from './ui/alerts/ErrorAlert';
|
||||
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';
|
||||
|
||||
type CreateBeerPostSchema = z.infer<typeof CreateBeerPostValidationSchema>;
|
||||
|
||||
interface BeerFormProps {
|
||||
breweries: z.infer<typeof BreweryPostQueryResult>[];
|
||||
types: BeerType[];
|
||||
}
|
||||
|
||||
const CreateBeerPostForm: FunctionComponent<BeerFormProps> = ({
|
||||
breweries = [],
|
||||
types = [],
|
||||
}) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<CreateBeerPostSchema>({
|
||||
resolver: zodResolver(CreateBeerPostValidationSchema),
|
||||
});
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const onSubmit: SubmitHandler<CreateBeerPostSchema> = async (data) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const response = await sendCreateBeerPostRequest(data);
|
||||
router.push(`/beers/${response.id}`);
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error)) {
|
||||
setError('Something went wrong');
|
||||
return;
|
||||
}
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="form-control" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>{error && <ErrorAlert error={error} setError={setError} />}</div>
|
||||
<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">
|
||||
<div className="mb-2 w-full md:mb-0 md:w-1/2 md:pr-3">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="breweryId">Brewery</FormLabel>
|
||||
<FormError>{errors.breweryId?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormSelect
|
||||
disabled={isSubmitting}
|
||||
formRegister={register('breweryId')}
|
||||
error={!!errors.breweryId}
|
||||
id="breweryId"
|
||||
options={breweries.map((brewery) => ({
|
||||
value: brewery.id,
|
||||
text: brewery.name,
|
||||
}))}
|
||||
placeholder="Brewery"
|
||||
message="Pick a brewery"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
<div className="mb-2 w-full md:mb-0 md:w-1/2 md:pl-3">
|
||||
<FormInfo>
|
||||
<FormLabel htmlFor="typeId">Type</FormLabel>
|
||||
<FormError>{errors.typeId?.message}</FormError>
|
||||
</FormInfo>
|
||||
<FormSegment>
|
||||
<FormSelect
|
||||
disabled={isSubmitting}
|
||||
formRegister={register('typeId')}
|
||||
error={!!errors.typeId}
|
||||
id="typeId"
|
||||
options={types.map((beerType) => ({
|
||||
value: beerType.id,
|
||||
text: beerType.name,
|
||||
}))}
|
||||
placeholder="Beer type"
|
||||
message="Pick a beer type"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" isSubmitting={isSubmitting}>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateBeerPostForm;
|
||||
147
src/components/EditBeerPostForm.tsx
Normal file
147
src/components/EditBeerPostForm.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import sendEditBeerPostRequest from '@/requests/sendEditBeerPostRequest';
|
||||
import EditBeerPostValidationSchema from '@/services/BeerPost/schema/EditBeerPostValidationSchema';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import { FC, useState } from 'react';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import ErrorAlert from './ui/alerts/ErrorAlert';
|
||||
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: { errors },
|
||||
} = useForm<EditBeerPostSchema>({
|
||||
resolver: zodResolver(EditBeerPostValidationSchema),
|
||||
defaultValues: previousValues,
|
||||
});
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const onSubmit: SubmitHandler<EditBeerPostSchema> = async (data) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await sendEditBeerPostRequest(data);
|
||||
router.push(`/beers/${data.id}`);
|
||||
} catch (e) {
|
||||
setIsSubmitting(false);
|
||||
if (!(e instanceof Error)) {
|
||||
setError('Something went wrong');
|
||||
return;
|
||||
}
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/beers/${previousValues.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.status === 200) {
|
||||
router.push('/beers');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form className="form-control" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-5">
|
||||
{error && <ErrorAlert error={error} setError={setError} />}
|
||||
</div>
|
||||
<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-primary btn w-full rounded-xl ${isSubmitting ? 'loading' : ''}`}
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditBeerPostForm;
|
||||
91
src/components/Login/LoginForm.tsx
Normal file
91
src/components/Login/LoginForm.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import sendLoginUserRequest from '@/requests/sendLoginUserRequest';
|
||||
import LoginValidationSchema from '@/services/User/schema/LoginValidationSchema';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useContext, useState } from 'react';
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import UserContext from '@/contexts/userContext';
|
||||
import ErrorAlert from '../ui/alerts/ErrorAlert';
|
||||
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';
|
||||
|
||||
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 [responseError, setResponseError] = useState<string>('');
|
||||
|
||||
const { mutate } = useContext(UserContext);
|
||||
|
||||
const onSubmit: SubmitHandler<LoginT> = async (data) => {
|
||||
try {
|
||||
await sendLoginUserRequest(data);
|
||||
await mutate!();
|
||||
await router.push(`/user/current`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setResponseError(error.message);
|
||||
reset();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="form-control w-full 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>
|
||||
|
||||
{responseError && <ErrorAlert error={responseError} setError={setResponseError} />}
|
||||
<div className="w-full">
|
||||
<Button type="submit" isSubmitting={formState.isSubmitting}>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
180
src/components/RegisterUserForm.tsx
Normal file
180
src/components/RegisterUserForm.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import sendRegisterUserRequest from '@/requests/sendRegisterUserRequest';
|
||||
import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FC, useState } from 'react';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import ErrorAlert from './ui/alerts/ErrorAlert';
|
||||
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 CreateUserValidationSchema>
|
||||
>({ resolver: zodResolver(CreateUserValidationSchema) });
|
||||
|
||||
const { errors } = formState;
|
||||
const [serverResponseError, setServerResponseError] = useState('');
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof CreateUserValidationSchema>) => {
|
||||
try {
|
||||
await sendRegisterUserRequest(data);
|
||||
reset();
|
||||
router.push('/', undefined, { shallow: true });
|
||||
} catch (error) {
|
||||
setServerResponseError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Something went wrong. We could not register your account.',
|
||||
);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form
|
||||
className="form-control w-full space-y-5"
|
||||
noValidate
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div>
|
||||
{serverResponseError && (
|
||||
<ErrorAlert error={serverResponseError} setError={setServerResponseError} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="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="first name"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
|
||||
<div className="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="last name"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="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="email"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
<div className="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="username"
|
||||
/>
|
||||
</FormSegment>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="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="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>
|
||||
<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 className="mt-6 w-full">
|
||||
<Button type="submit" isSubmitting={formState.isSubmitting}>
|
||||
Register User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterUserForm;
|
||||
15
src/components/ui/Layout.tsx
Normal file
15
src/components/ui/Layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { FC, ReactNode } from 'react';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
const Layout: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<Navbar />
|
||||
<div className="top-0 h-full flex-1 overflow-x-auto animate-in fade-in">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
106
src/components/ui/Navbar.tsx
Normal file
106
src/components/ui/Navbar.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
/* eslint-disable jsx-a11y/label-has-for */
|
||||
|
||||
import UserContext from '@/contexts/userContext';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
interface Page {
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
const Navbar = () => {
|
||||
const router = useRouter();
|
||||
const [currentURL, setCurrentURL] = useState('/');
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentURL(router.asPath);
|
||||
}, [router.asPath]);
|
||||
|
||||
const authenticatedPages: readonly Page[] = [
|
||||
{ slug: '/account', name: 'Account' },
|
||||
{ slug: '/api/users/logout', name: 'Logout' },
|
||||
];
|
||||
|
||||
const unauthenticatedPages: readonly Page[] = [
|
||||
{ slug: '/login', name: 'Login' },
|
||||
{ slug: '/register', name: 'Register' },
|
||||
];
|
||||
|
||||
const otherPages: readonly Page[] = [
|
||||
{ slug: '/beers', name: 'Beers' },
|
||||
{ slug: '/breweries', name: 'Breweries' },
|
||||
];
|
||||
|
||||
const pages: readonly Page[] = [
|
||||
...otherPages,
|
||||
...(user ? authenticatedPages : unauthenticatedPages),
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="navbar sticky top-0 z-50 bg-primary text-primary-content">
|
||||
<div className="flex-1">
|
||||
<Link className="btn-ghost btn normal-case" href="/">
|
||||
<span className="cursor-pointer text-lg font-bold">The Biergarten App</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden flex-none lg:block">
|
||||
<ul className="menu menu-horizontal p-0">
|
||||
{pages.map((page) => {
|
||||
return (
|
||||
<li key={page.slug}>
|
||||
<Link tabIndex={0} href={page.slug}>
|
||||
<span
|
||||
className={`text-lg uppercase ${
|
||||
currentURL === page.slug ? 'font-extrabold' : 'font-semibold'
|
||||
} text-primary-content`}
|
||||
>
|
||||
{page.name}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex-none lg:hidden">
|
||||
<div className="dropdown dropdown-end">
|
||||
<label tabIndex={0} className="btn-ghost btn-circle btn">
|
||||
<span className="w-10 rounded-full">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block h-5 w-5 stroke-white"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</label>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content menu rounded-box menu-compact mt-3 w-48 bg-base-100 p-2 shadow"
|
||||
>
|
||||
{pages.map((page) => (
|
||||
<li key={page.slug}>
|
||||
<Link href={page.slug}>
|
||||
<span className="select-none text-primary-content">{page.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
export default Navbar;
|
||||
44
src/components/ui/Spinner.tsx
Normal file
44
src/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
32
src/components/ui/alerts/ErrorAlert.tsx
Normal file
32
src/components/ui/alerts/ErrorAlert.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Dispatch, FC, SetStateAction } from 'react';
|
||||
import { FiAlertTriangle } from 'react-icons/fi';
|
||||
|
||||
interface ErrorAlertProps {
|
||||
error: string;
|
||||
setError: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
const ErrorAlert: FC<ErrorAlertProps> = ({ error, setError }) => {
|
||||
return (
|
||||
<div className="alert alert-error shadow-lg">
|
||||
<div>
|
||||
<FiAlertTriangle className="h-6 w-6" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-none">
|
||||
<button
|
||||
className="btn-ghost btn-sm btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorAlert;
|
||||
23
src/components/ui/forms/Button.tsx
Normal file
23
src/components/ui/forms/Button.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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-primary btn w-full rounded-xl ${isSubmitting ? 'loading' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default Button;
|
||||
16
src/components/ui/forms/FormError.tsx
Normal file
16
src/components/ui/forms/FormError.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
18
src/components/ui/forms/FormInfo.tsx
Normal file
18
src/components/ui/forms/FormInfo.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
21
src/components/ui/forms/FormLabel.tsx
Normal file
21
src/components/ui/forms/FormLabel.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
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-sm font-extrabold uppercase tracking-wide sm:text-xs"
|
||||
htmlFor={htmlFor}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
export default FormLabel;
|
||||
39
src/components/ui/forms/FormPageLayout.tsx
Normal file
39
src/components/ui/forms/FormPageLayout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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="align-center my-20 flex h-fit flex-col items-center justify-center">
|
||||
<div className="w-8/12">
|
||||
<div className="tooltip tooltip-bottom absolute" data-tip={backLinkText}>
|
||||
<Link href={backLink} className="btn-ghost btn-sm btn 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-3xl font-bold">{headingText}</h1>
|
||||
</div>
|
||||
<div>{FormComponent}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormPageLayout;
|
||||
12
src/components/ui/forms/FormSegment.tsx
Normal file
12
src/components/ui/forms/FormSegment.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
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;
|
||||
64
src/components/ui/forms/FormSelect.tsx
Normal file
64
src/components/ui/forms/FormSelect.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
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-bordered select 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;
|
||||
52
src/components/ui/forms/FormTextArea.tsx
Normal file
52
src/components/ui/forms/FormTextArea.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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={`textarea-bordered textarea m-0 w-full resize-none rounded-lg border border-solid bg-clip-padding transition ease-in-out ${
|
||||
error ? 'textarea-error' : ''
|
||||
}`}
|
||||
{...formValidationSchema}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
export default FormTextArea;
|
||||
57
src/components/ui/forms/FormTextInput.tsx
Normal file
57
src/components/ui/forms/FormTextInput.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
const FormTextInput: FunctionComponent<FormInputProps> = ({
|
||||
placeholder = '',
|
||||
formValidationSchema,
|
||||
error,
|
||||
type,
|
||||
id,
|
||||
disabled = false,
|
||||
}) => (
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
className={`input-bordered input w-full rounded-lg transition ease-in-out ${
|
||||
error ? 'input-error' : ''
|
||||
}`}
|
||||
{...formValidationSchema}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
export default FormTextInput;
|
||||
Reference in New Issue
Block a user