Skip to content

Commit

Permalink
[FE] feat: 꿀조합 댓글 기능 구현 (#744)
Browse files Browse the repository at this point in the history
* feat: CommentItem 컴포넌트 추가

* feat: 댓글 목록 가져오는 쿼리 추가

* feat: 상품 상세 페이지에 댓글 컴포넌트 추가

* feat: Input 컴포넌트 속성에 minWidth값 추가

* feat: CommentInput 컴포넌트 추가

* feat: 댓글 등록 기능 구현

* feat: 사용자가 입력한 글자수 UI 추가

* feat: 리뷰 반영

* feat: text area 텍스트 크기 수정

* feat: CommentList 컴포넌트 추가

* feat: 디자인 수정

* feat: api 변경 적용

* refactor: CommentInput -> CommentForm으로 네이밍 수정

* feat: data fetching 로직을 CommentList내부로 이동

* feat: 댓글 무한 스크롤로 변경

* fix: 토스트 컴포넌트가 가운데 정렬되지 않는 문제 해결

* feat: 전송 아이콘 추가

* feat: 댓글 컴포넌트를 fixed로 변경

* feat: 댓글 컴포넌트 사이 공백 추가

* feat: Response 객체에 totalElements 값 추가

* feat: pageParam의 기본값 추가

* feat: index.ts에서 export문 추가
  • Loading branch information
xodms0309 authored Oct 13, 2023
1 parent dc20956 commit 3a950c2
Show file tree
Hide file tree
Showing 22 changed files with 399 additions and 25 deletions.
5 changes: 5 additions & 0 deletions frontend/.storybook/preview-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@
d="M3 4V1h2v3h3v2H5v3H3V6H0V4m6 6V7h3V4h7l1.8 2H21c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V10m10 9c4.45 0 6.69-5.38 3.54-8.54C13.39 7.31 8 9.55 8 14c0 2.76 2.24 5 5 5m-3.2-5c0 2.85 3.45 4.28 5.46 2.26c2.02-2.01.59-5.46-2.26-5.46A3.21 3.21 0 0 0 9.8 14Z"
/>
</symbol>
<symbol id="plane" width="32" height="32" viewBox="0 0 256 256">
<path
d="M232 127.89a16 16 0 0 1-8.18 14L55.91 237.9A16.14 16.14 0 0 1 48 240a16 16 0 0 1-15.05-21.34l27.35-79.95a4 4 0 0 1 3.79-2.71H136a8 8 0 0 0 8-8.53a8.19 8.19 0 0 0-8.26-7.47H64.16a4 4 0 0 1-3.79-2.7l-27.44-80a16 16 0 0 1 22.92-19.23l168 95.89a16 16 0 0 1 8.15 13.93Z"
/>
</symbol>
</svg>
</div>
<div id="toast-container"></div>
11 changes: 8 additions & 3 deletions frontend/src/components/Common/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ interface InputProps extends ComponentPropsWithRef<'input'> {
* Input 컴포넌트의 너비값입니다.
*/
customWidth?: string;
/**
* Input 컴포넌트의 최소 너비값입니다.
*/
minWidth?: string;
/**
* Input value에 에러가 있는지 여부입니다.
*/
Expand All @@ -24,12 +28,12 @@ interface InputProps extends ComponentPropsWithRef<'input'> {

const Input = forwardRef(
(
{ customWidth = '300px', isError = false, rightIcon, errorMessage, ...props }: InputProps,
{ customWidth = '300px', minWidth, isError = false, rightIcon, errorMessage, ...props }: InputProps,
ref: ForwardedRef<HTMLInputElement>
) => {
return (
<>
<InputContainer customWidth={customWidth}>
<InputContainer customWidth={customWidth} minWidth={minWidth}>
<CustomInput ref={ref} isError={isError} {...props} />
{rightIcon && <IconWrapper>{rightIcon}</IconWrapper>}
</InputContainer>
Expand All @@ -43,11 +47,12 @@ Input.displayName = 'Input';

export default Input;

type InputContainerStyleProps = Pick<InputProps, 'customWidth'>;
type InputContainerStyleProps = Pick<InputProps, 'customWidth' | 'minWidth'>;
type CustomInputStyleProps = Pick<InputProps, 'isError'>;

const InputContainer = styled.div<InputContainerStyleProps>`
position: relative;
min-width: ${({ minWidth }) => minWidth ?? 0};
max-width: ${({ customWidth }) => customWidth};
text-align: center;
`;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Common/Svg/SvgIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const SVG_ICON_VARIANTS = [
'plus',
'pencil',
'camera',
'plane',
] as const;
export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number];

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/Common/Svg/SvgSprite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ const SvgSprite = () => {
<symbol viewBox="0 0 24 24" id="camera">
<path d="M3 4V1h2v3h3v2H5v3H3V6H0V4m6 6V7h3V4h7l1.8 2H21c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V10m10 9c4.45 0 6.69-5.38 3.54-8.54C13.39 7.31 8 9.55 8 14c0 2.76 2.24 5 5 5m-3.2-5c0 2.85 3.45 4.28 5.46 2.26c2.02-2.01.59-5.46-2.26-5.46A3.21 3.21 0 0 0 9.8 14Z" />
</symbol>
<symbol id="plane" viewBox="0 0 256 256">
<path d="M232 127.89a16 16 0 0 1-8.18 14L55.91 237.9A16.14 16.14 0 0 1 48 240a16 16 0 0 1-15.05-21.34l27.35-79.95a4 4 0 0 1 3.79-2.71H136a8 8 0 0 0 8-8.53a8.19 8.19 0 0 0-8.26-7.47H64.16a4 4 0 0 1-3.79-2.7l-27.44-80a16 16 0 0 1 22.92-19.23l168 95.89a16 16 0 0 1 8.15 13.93Z" />
</symbol>
</svg>
);
};
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Common/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type ToastStyleProps = Pick<ToastProps, 'isError'> & { isAnimating?: boolean };

const ToastWrapper = styled.div<ToastStyleProps>`
position: relative;
width: 100%;
width: calc(100% - 20px);
height: 55px;
max-width: 560px;
border-radius: 10px;
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/components/Recipe/CommentForm/CommentForm.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react';

import CommentForm from './CommentForm';

const meta: Meta<typeof CommentForm> = {
title: 'recipe/CommentForm',
component: CommentForm,
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
101 changes: 101 additions & 0 deletions frontend/src/components/Recipe/CommentForm/CommentForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Button, Spacing, Text, Textarea, useTheme } from '@fun-eat/design-system';
import type { ChangeEventHandler, FormEventHandler } from 'react';
import { useState } from 'react';
import styled from 'styled-components';

import { SvgIcon } from '@/components/Common';
import { useToastActionContext } from '@/hooks/context';
import { useRecipeCommentMutation } from '@/hooks/queries/recipe';

interface CommentFormProps {
recipeId: number;
}

const MAX_COMMENT_LENGTH = 200;

const CommentForm = ({ recipeId }: CommentFormProps) => {
const [commentValue, setCommentValue] = useState('');
const { mutate } = useRecipeCommentMutation(recipeId);

const theme = useTheme();
const { toast } = useToastActionContext();

const handleCommentInput: ChangeEventHandler<HTMLTextAreaElement> = (e) => {
setCommentValue(e.target.value);
};

const handleSubmitComment: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault();

mutate(
{ comment: commentValue },
{
onSuccess: () => {
setCommentValue('');
toast.success('댓글이 등록되었습니다.');
},
onError: (error) => {
if (error instanceof Error) {
toast.error(error.message);
return;
}

toast.error('댓글을 등록하는데 오류가 발생했습니다.');
},
}
);
};

return (
<CommentFormContainer>
<Form onSubmit={handleSubmitComment}>
<CommentTextarea
placeholder="댓글을 입력하세요. (200자)"
value={commentValue}
onChange={handleCommentInput}
maxLength={MAX_COMMENT_LENGTH}
/>
<SubmitButton variant="transparent" disabled={commentValue.length === 0}>
<SvgIcon
variant="plane"
width={30}
height={30}
color={commentValue.length === 0 ? theme.colors.gray2 : theme.colors.gray4}
/>
</SubmitButton>
</Form>
<Spacing size={8} />
<Text size="xs" color={theme.textColors.info} align="right">
{commentValue.length}자 / {MAX_COMMENT_LENGTH}
</Text>
</CommentFormContainer>
);
};

export default CommentForm;

const CommentFormContainer = styled.div`
position: fixed;
bottom: 0;
width: calc(100% - 40px);
max-width: 540px;
padding: 16px 0;
background: ${({ theme }) => theme.backgroundColors.default};
`;

const Form = styled.form`
display: flex;
gap: 4px;
justify-content: space-around;
align-items: center;
`;

const CommentTextarea = styled(Textarea)`
height: 50px;
padding: 8px;
font-size: 1.4rem;
`;

const SubmitButton = styled(Button)`
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
`;
18 changes: 18 additions & 0 deletions frontend/src/components/Recipe/CommentItem/CommentItem.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react';

import CommentItem from './CommentItem';

import comments from '@/mocks/data/comments.json';

const meta: Meta<typeof CommentItem> = {
title: 'recipe/CommentItem',
component: CommentItem,
args: {
recipeComment: comments.comments[0],
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
50 changes: 50 additions & 0 deletions frontend/src/components/Recipe/CommentItem/CommentItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Divider, Spacing, Text, useTheme } from '@fun-eat/design-system';
import styled from 'styled-components';

import type { Comment } from '@/types/recipe';
import { getFormattedDate } from '@/utils/date';

interface CommentItemProps {
recipeComment: Comment;
}

const CommentItem = ({ recipeComment }: CommentItemProps) => {
const theme = useTheme();
const { author, comment, createdAt } = recipeComment;

return (
<>
<AuthorWrapper>
<AuthorProfileImage src={author.profileImage} alt={`${author.nickname}님의 프로필`} width={32} height={32} />
<div>
<Text size="xs" color={theme.textColors.info}>
{author.nickname}
</Text>
<Text size="xs" color={theme.textColors.info}>
{getFormattedDate(createdAt)}
</Text>
</div>
</AuthorWrapper>
<CommentContent size="sm">{comment}</CommentContent>
<Divider variant="disabled" />
<Spacing size={16} />
</>
);
};

export default CommentItem;

const AuthorWrapper = styled.div`
display: flex;
gap: 12px;
align-items: center;
`;

const AuthorProfileImage = styled.img`
border: 1px solid ${({ theme }) => theme.colors.primary};
border-radius: 50%;
`;

const CommentContent = styled(Text)`
margin: 16px 0;
`;
13 changes: 13 additions & 0 deletions frontend/src/components/Recipe/CommentList/CommentList.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react';

import CommentList from './CommentList';

const meta: Meta<typeof CommentList> = {
title: 'recipe/CommentList',
component: CommentList,
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
35 changes: 35 additions & 0 deletions frontend/src/components/Recipe/CommentList/CommentList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Heading, Spacing } from '@fun-eat/design-system';
import { useRef } from 'react';

import CommentItem from '../CommentItem/CommentItem';

import { useIntersectionObserver } from '@/hooks/common';
import { useInfiniteRecipeCommentQuery } from '@/hooks/queries/recipe';

interface CommentListProps {
recipeId: number;
}

const CommentList = ({ recipeId }: CommentListProps) => {
const scrollRef = useRef<HTMLDivElement>(null);

const { fetchNextPage, hasNextPage, data } = useInfiniteRecipeCommentQuery(Number(recipeId));
useIntersectionObserver<HTMLDivElement>(fetchNextPage, scrollRef, hasNextPage);

const comments = data.pages.flatMap((page) => page.comments);

return (
<section>
<Heading as="h3" size="lg">
댓글 ({comments.length}개)
</Heading>
<Spacing size={12} />
{comments.map((comment) => (
<CommentItem key={comment.id} recipeComment={comment} />
))}
<div ref={scrollRef} style={{ height: '1px' }} aria-hidden />
</section>
);
};

export default CommentList;
3 changes: 3 additions & 0 deletions frontend/src/components/Recipe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ export { default as RecipeItem } from './RecipeItem/RecipeItem';
export { default as RecipeList } from './RecipeList/RecipeList';
export { default as RecipeRegisterForm } from './RecipeRegisterForm/RecipeRegisterForm';
export { default as RecipeFavorite } from './RecipeFavorite/RecipeFavorite';
export { default as CommentItem } from './CommentItem/CommentItem';
export { default as CommentForm } from './CommentForm/CommentForm';
export { default as CommentList } from './CommentList/CommentList';
2 changes: 1 addition & 1 deletion frontend/src/contexts/ToastContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,6 @@ const ToastContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
width: calc(100% - 20px);
width: 100%;
transform: translate(0, -10px);
`;
2 changes: 2 additions & 0 deletions frontend/src/hooks/queries/recipe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export { default as useRecipeDetailQuery } from './useRecipeDetailQuery';
export { default as useRecipeRegisterFormMutation } from './useRecipeRegisterFormMutation';
export { default as useRecipeFavoriteMutation } from './useRecipeFavoriteMutation';
export { default as useInfiniteRecipesQuery } from './useInfiniteRecipesQuery';
export { default as useInfiniteRecipeCommentQuery } from './useInfiniteRecipeCommentQuery';
export { default as useRecipeCommentMutation } from './useRecipeCommentMutation';
36 changes: 36 additions & 0 deletions frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useSuspendedInfiniteQuery } from '../useSuspendedInfiniteQuery';

import { recipeApi } from '@/apis';
import type { CommentResponse } from '@/types/response';

interface PageParam {
lastId: number;
totalElements: number | null;
}

const fetchRecipeComments = async (pageParam: PageParam, recipeId: number) => {
const { lastId, totalElements } = pageParam;
const response = await recipeApi.get({
params: `/${recipeId}/comments`,
queries: `?lastId=${lastId}&totalElements=${totalElements}`,
});
const data: CommentResponse = await response.json();
return data;
};

const useInfiniteRecipeCommentQuery = (recipeId: number) => {
return useSuspendedInfiniteQuery(
['recipeComment', recipeId],
({ pageParam = { lastId: 0, totalElements: null } }) => fetchRecipeComments(pageParam, recipeId),
{
getNextPageParam: (prevResponse: CommentResponse) => {
const lastId = prevResponse.comments[prevResponse.comments.length - 1].id;
const totalElements = prevResponse.totalElements;
const lastCursor = { lastId: lastId, totalElements: totalElements };
return prevResponse.hasNext ? lastCursor : undefined;
},
}
);
};

export default useInfiniteRecipeCommentQuery;
24 changes: 24 additions & 0 deletions frontend/src/hooks/queries/recipe/useRecipeCommentMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { recipeApi } from '@/apis';

interface RecipeCommentRequestBody {
comment: string;
}

const headers = { 'Content-Type': 'application/json' };

const postRecipeComment = (recipeId: number, body: RecipeCommentRequestBody) => {
return recipeApi.post({ params: `/${recipeId}/comments`, credentials: true }, headers, body);
};

const useRecipeCommentMutation = (recipeId: number) => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (body: RecipeCommentRequestBody) => postRecipeComment(recipeId, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recipeComment', recipeId] }),
});
};

export default useRecipeCommentMutation;
Loading

0 comments on commit 3a950c2

Please sign in to comment.