-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[FE] feat: 꿀조합 댓글 기능 구현 #744
Changes from 7 commits
162d561
6a47996
7d849bc
5dc497f
e713a04
f5c90fb
33b36c0
9a09d11
b0a25cc
0e5fa37
02001e1
78a91ba
7dba7c3
89af090
67a109b
996e9d0
6b4e689
d6ed26b
a860bb6
838d38e
980e205
a08dd8e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import type { Meta, StoryObj } from '@storybook/react'; | ||
|
||
import CommentInput from './CommentInput'; | ||
|
||
const meta: Meta<typeof CommentInput> = { | ||
title: 'recipe/CommentInput', | ||
component: CommentInput, | ||
}; | ||
|
||
export default meta; | ||
type Story = StoryObj<typeof meta>; | ||
|
||
export const Default: Story = {}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import { Button, Text, useTheme } from '@fun-eat/design-system'; | ||
import type { ChangeEventHandler, FormEventHandler } from 'react'; | ||
import { useState } from 'react'; | ||
import styled from 'styled-components'; | ||
|
||
import { Input } from '@/components/Common'; | ||
import { useToastActionContext } from '@/hooks/context'; | ||
import useRecipeCommentMutation from '@/hooks/queries/recipe/useRecipeCommentMutation'; | ||
|
||
interface CommentInputProps { | ||
recipeId: number; | ||
} | ||
|
||
const MAX_COMMENT_LENGTH = 200; | ||
|
||
const CommentInput = ({ recipeId }: CommentInputProps) => { | ||
const [commentValue, setCommentValue] = useState(''); | ||
const { mutate } = useRecipeCommentMutation(recipeId); | ||
|
||
const theme = useTheme(); | ||
const { toast } = useToastActionContext(); | ||
|
||
const handleCommentInput: ChangeEventHandler<HTMLInputElement> = (e) => { | ||
setCommentValue(e.target.value); | ||
}; | ||
|
||
const handleSubmitComment: FormEventHandler<HTMLFormElement> = async (e) => { | ||
e.preventDefault(); | ||
|
||
mutate( | ||
{ content: commentValue }, | ||
{ | ||
onSuccess: () => { | ||
setCommentValue(''); | ||
toast.success('댓글이 등록되었습니다.'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🥪 |
||
}, | ||
onError: (error) => { | ||
if (error instanceof Error) { | ||
alert(error.message); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지워도 될 거 같네여 |
||
return; | ||
} | ||
|
||
toast.error('댓글을 등록하는데 오류가 발생했습니다.'); | ||
}, | ||
} | ||
); | ||
}; | ||
|
||
return ( | ||
<> | ||
<CommentInputForm onSubmit={handleSubmitComment}> | ||
<Input | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 200자면 Input으로 했을 때 내용이 다 보이지 않을거 같아서 Textarea가 좋아보이는데 어떻게 생각하시나요?? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 좋아요 ~ |
||
placeholder="댓글을 입력하세요. (200자)" | ||
minWidth="90%" | ||
value={commentValue} | ||
onChange={handleCommentInput} | ||
/> | ||
<SubmitButton size="xs" customWidth="40px" disabled={commentValue.length === 0}> | ||
등록 | ||
</SubmitButton> | ||
</CommentInputForm> | ||
<Text size="xs" color={theme.textColors.info} align="right"> | ||
{commentValue.length} / {MAX_COMMENT_LENGTH} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 스크린리더를 위해 '자'를 넣어주세요! |
||
</Text> | ||
</> | ||
); | ||
}; | ||
|
||
export default CommentInput; | ||
|
||
const CommentInputForm = styled.form` | ||
display: flex; | ||
gap: 4px; | ||
justify-content: space-around; | ||
`; | ||
|
||
const SubmitButton = styled(Button)` | ||
background: ${({ theme, disabled }) => (disabled ? theme.colors.gray2 : theme.colors.primary)}; | ||
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; | ||
`; |
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: { | ||
comment: comments[0], | ||
}, | ||
}; | ||
|
||
export default meta; | ||
type Story = StoryObj<typeof meta>; | ||
|
||
export const Default: Story = {}; |
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 { | ||
comment: Comment; | ||
} | ||
|
||
const CommentItem = ({ comment }: CommentItemProps) => { | ||
const theme = useTheme(); | ||
const { author, content, createdAt } = comment; | ||
|
||
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">{content}</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; | ||
`; |
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 { | ||
content: 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; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import { useSuspendedQuery } from '../useSuspendedQuery'; | ||
|
||
import { recipeApi } from '@/apis'; | ||
import type { Comment } from '@/types/recipe'; | ||
|
||
const fetchRecipeComments = async (recipeId: number) => { | ||
const response = await recipeApi.get({ params: `/${recipeId}/comments` }); | ||
const data: Comment[] = await response.json(); | ||
return data; | ||
}; | ||
|
||
const useRecipeCommentQuery = (recipeId: number) => { | ||
return useSuspendedQuery(['recipeComment', recipeId], () => fetchRecipeComments(recipeId)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 무한스크롤! 백엔드와 이야기해보세요~ |
||
}; | ||
|
||
export default useRecipeCommentQuery; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
[ | ||
{ | ||
"author": { | ||
"nickname": "펀잇", | ||
"profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34" | ||
}, | ||
"content": "저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. ", | ||
"createdAt": "2023-08-09T10:10:10", | ||
"id": 1 | ||
}, | ||
{ | ||
"author": { | ||
"nickname": "펀잇", | ||
"profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34" | ||
}, | ||
"content": "string", | ||
"createdAt": "2023-08-09T10:10:10", | ||
"id": 1 | ||
}, | ||
{ | ||
"author": { | ||
"nickname": "펀잇", | ||
"profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34" | ||
}, | ||
"content": "string", | ||
"createdAt": "2023-08-09T10:10:10", | ||
"id": 1 | ||
} | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,17 +1,18 @@ | ||
import { Heading, Spacing, Text, theme } from '@fun-eat/design-system'; | ||
import { Divider, Heading, Spacing, Text, theme } from '@fun-eat/design-system'; | ||
import { useParams } from 'react-router-dom'; | ||
import styled from 'styled-components'; | ||
|
||
import RecipePreviewImage from '@/assets/plate.svg'; | ||
import { SectionTitle } from '@/components/Common'; | ||
import { RecipeFavorite } from '@/components/Recipe'; | ||
import { useRecipeDetailQuery } from '@/hooks/queries/recipe'; | ||
import { CommentInput, CommentItem, RecipeFavorite } from '@/components/Recipe'; | ||
import { useRecipeCommentQuery, useRecipeDetailQuery } from '@/hooks/queries/recipe'; | ||
import { getFormattedDate } from '@/utils/date'; | ||
|
||
export const RecipeDetailPage = () => { | ||
const { recipeId } = useParams(); | ||
|
||
const { data: recipeDetail } = useRecipeDetailQuery(Number(recipeId)); | ||
const { data: recipeComments } = useRecipeCommentQuery(Number(recipeId)); | ||
const { id, images, title, content, author, products, totalPrice, favoriteCount, favorite, createdAt } = recipeDetail; | ||
|
||
return ( | ||
|
@@ -65,7 +66,18 @@ export const RecipeDetailPage = () => { | |
<RecipeContent size="lg" lineHeight="lg"> | ||
{content} | ||
</RecipeContent> | ||
<Spacing size={40} /> | ||
<Spacing size={16} /> | ||
<Divider variant="disabled" /> | ||
<Spacing size={16} /> | ||
<Heading as="h3" size="lg"> | ||
댓글 ({recipeComments.length}개) | ||
</Heading> | ||
<Spacing size={12} /> | ||
{recipeComments.map((comment) => ( | ||
<CommentItem key={comment.id} comment={comment} /> | ||
))} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. List 컴포넌트를 만들어서 Suspense 적용하는건 어떻게 생각하시나요?? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 옙~ |
||
<CommentInput recipeId={Number(recipeId)} /> | ||
<Spacing size={12} /> | ||
</RecipeDetailPageContainer> | ||
); | ||
}; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
async 없어도 잘 동작하겠네요!