-
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 13 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 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 = {}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
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 { useToastActionContext } from '@/hooks/context'; | ||
import useRecipeCommentMutation from '@/hooks/queries/recipe/useRecipeCommentMutation'; | ||
|
||
interface CommentFormProps { | ||
recipeId: number; | ||
} | ||
|
||
const MAX_COMMENT_LENGTH = 200; | ||
|
||
const CommentForm = ({ recipeId }: CommentFormProps) => { | ||
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. 👍 |
||
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 ( | ||
<> | ||
<Form onSubmit={handleSubmitComment}> | ||
<CommentTextarea | ||
placeholder="댓글을 입력하세요. (200자)" | ||
value={commentValue} | ||
onChange={handleCommentInput} | ||
maxLength={MAX_COMMENT_LENGTH} | ||
/> | ||
<SubmitButton size="xs" customWidth="40px" customHeight="auto" disabled={commentValue.length === 0}> | ||
등록 | ||
</SubmitButton> | ||
</Form> | ||
<Spacing size={8} /> | ||
<Text size="xs" color={theme.textColors.info} align="right"> | ||
{commentValue.length}자 / {MAX_COMMENT_LENGTH}자 | ||
</Text> | ||
</> | ||
); | ||
}; | ||
|
||
export default CommentForm; | ||
|
||
const Form = styled.form` | ||
display: flex; | ||
gap: 4px; | ||
justify-content: space-around; | ||
`; | ||
|
||
const CommentTextarea = styled(Textarea)` | ||
width: calc(100% - 50px); | ||
padding: 8px; | ||
font-size: 1.4rem; | ||
`; | ||
|
||
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: { | ||
recipeComment: 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 { | ||
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; | ||
`; |
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 = {}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import CommentItem from '../CommentItem/CommentItem'; | ||
|
||
import type { Comment } from '@/types/recipe'; | ||
|
||
interface CommentListProps { | ||
comments: Comment[]; | ||
} | ||
|
||
const CommentList = ({ comments }: CommentListProps) => { | ||
return ( | ||
<> | ||
{comments.map((comment) => ( | ||
<CommentItem key={comment.id} recipeComment={comment} /> | ||
))} | ||
</> | ||
); | ||
}; | ||
|
||
export default CommentList; |
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; |
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" | ||
}, | ||
"comment": "저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. ", | ||
"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" | ||
}, | ||
"comment": "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" | ||
}, | ||
"comment": "string", | ||
"createdAt": "2023-08-09T10:10:10", | ||
"id": 1 | ||
} | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,17 +1,22 @@ | ||
import { Heading, Spacing, Text, theme } from '@fun-eat/design-system'; | ||
import { Divider, Heading, Spacing, Text, theme } from '@fun-eat/design-system'; | ||
import { useQueryErrorResetBoundary } from '@tanstack/react-query'; | ||
import { Suspense } from 'react'; | ||
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 { ErrorBoundary, ErrorComponent, Loading, SectionTitle } from '@/components/Common'; | ||
import { CommentForm, CommentList, 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 { reset } = useQueryErrorResetBoundary(); | ||
|
||
const { id, images, title, content, author, products, totalPrice, favoriteCount, favorite, createdAt } = recipeDetail; | ||
|
||
return ( | ||
|
@@ -65,7 +70,20 @@ export const RecipeDetailPage = () => { | |
<RecipeContent size="lg" lineHeight="lg"> | ||
{content} | ||
</RecipeContent> | ||
<Spacing size={40} /> | ||
<Spacing size={24} /> | ||
<Divider variant="disabled" customHeight="2px" /> | ||
<Spacing size={24} /> | ||
<Heading as="h3" size="lg"> | ||
댓글 ({recipeComments.length}개) | ||
</Heading> | ||
<Spacing size={12} /> | ||
<ErrorBoundary fallback={ErrorComponent} handleReset={reset}> | ||
<Suspense fallback={<Loading />}> | ||
<CommentList comments={recipeComments} /> | ||
</Suspense> | ||
</ErrorBoundary> | ||
<CommentForm recipeId={Number(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. 하나를 말해도 열을 아는 천재 타미 |
||
<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.
index에 추가해주세요.!