diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index c74febeca..4e44004eb 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -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" /> + + +
diff --git a/frontend/src/components/Common/Input/Input.tsx b/frontend/src/components/Common/Input/Input.tsx index 59f86743e..c3b3b40f1 100644 --- a/frontend/src/components/Common/Input/Input.tsx +++ b/frontend/src/components/Common/Input/Input.tsx @@ -8,6 +8,10 @@ interface InputProps extends ComponentPropsWithRef<'input'> { * Input 컴포넌트의 너비값입니다. */ customWidth?: string; + /** + * Input 컴포넌트의 최소 너비값입니다. + */ + minWidth?: string; /** * Input value에 에러가 있는지 여부입니다. */ @@ -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 ) => { return ( <> - + {rightIcon && {rightIcon}} @@ -43,11 +47,12 @@ Input.displayName = 'Input'; export default Input; -type InputContainerStyleProps = Pick; +type InputContainerStyleProps = Pick; type CustomInputStyleProps = Pick; const InputContainer = styled.div` position: relative; + min-width: ${({ minWidth }) => minWidth ?? 0}; max-width: ${({ customWidth }) => customWidth}; text-align: center; `; diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx index 7287cbd5e..d8d11d326 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -21,6 +21,7 @@ export const SVG_ICON_VARIANTS = [ 'plus', 'pencil', 'camera', + 'plane', ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/frontend/src/components/Common/Svg/SvgSprite.tsx b/frontend/src/components/Common/Svg/SvgSprite.tsx index f4d7a1937..b4811ca73 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -74,6 +74,9 @@ const SvgSprite = () => { + + + ); }; diff --git a/frontend/src/components/Common/Toast/Toast.tsx b/frontend/src/components/Common/Toast/Toast.tsx index 28571c6af..c9d9dc46f 100644 --- a/frontend/src/components/Common/Toast/Toast.tsx +++ b/frontend/src/components/Common/Toast/Toast.tsx @@ -27,7 +27,7 @@ type ToastStyleProps = Pick & { isAnimating?: boolean }; const ToastWrapper = styled.div` position: relative; - width: 100%; + width: calc(100% - 20px); height: 55px; max-width: 560px; border-radius: 10px; diff --git a/frontend/src/components/Recipe/CommentForm/CommentForm.stories.tsx b/frontend/src/components/Recipe/CommentForm/CommentForm.stories.tsx new file mode 100644 index 000000000..e65b87225 --- /dev/null +++ b/frontend/src/components/Recipe/CommentForm/CommentForm.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import CommentForm from './CommentForm'; + +const meta: Meta = { + title: 'recipe/CommentForm', + component: CommentForm, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Recipe/CommentForm/CommentForm.tsx b/frontend/src/components/Recipe/CommentForm/CommentForm.tsx new file mode 100644 index 000000000..33c03d1e2 --- /dev/null +++ b/frontend/src/components/Recipe/CommentForm/CommentForm.tsx @@ -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 = (e) => { + setCommentValue(e.target.value); + }; + + const handleSubmitComment: FormEventHandler = (e) => { + e.preventDefault(); + + mutate( + { comment: commentValue }, + { + onSuccess: () => { + setCommentValue(''); + toast.success('댓글이 등록되었습니다.'); + }, + onError: (error) => { + if (error instanceof Error) { + toast.error(error.message); + return; + } + + toast.error('댓글을 등록하는데 오류가 발생했습니다.'); + }, + } + ); + }; + + return ( + +
+ + + + + + + + {commentValue.length}자 / {MAX_COMMENT_LENGTH}자 + +
+ ); +}; + +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')}; +`; diff --git a/frontend/src/components/Recipe/CommentItem/CommentItem.stories.tsx b/frontend/src/components/Recipe/CommentItem/CommentItem.stories.tsx new file mode 100644 index 000000000..70bf1f9a6 --- /dev/null +++ b/frontend/src/components/Recipe/CommentItem/CommentItem.stories.tsx @@ -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 = { + title: 'recipe/CommentItem', + component: CommentItem, + args: { + recipeComment: comments.comments[0], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Recipe/CommentItem/CommentItem.tsx b/frontend/src/components/Recipe/CommentItem/CommentItem.tsx new file mode 100644 index 000000000..847194b75 --- /dev/null +++ b/frontend/src/components/Recipe/CommentItem/CommentItem.tsx @@ -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 ( + <> + + +
+ + {author.nickname} 님 + + + {getFormattedDate(createdAt)} + +
+
+ {comment} + + + + ); +}; + +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; +`; diff --git a/frontend/src/components/Recipe/CommentList/CommentList.stories.tsx b/frontend/src/components/Recipe/CommentList/CommentList.stories.tsx new file mode 100644 index 000000000..ebad218de --- /dev/null +++ b/frontend/src/components/Recipe/CommentList/CommentList.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import CommentList from './CommentList'; + +const meta: Meta = { + title: 'recipe/CommentList', + component: CommentList, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Recipe/CommentList/CommentList.tsx b/frontend/src/components/Recipe/CommentList/CommentList.tsx new file mode 100644 index 000000000..5a34feb95 --- /dev/null +++ b/frontend/src/components/Recipe/CommentList/CommentList.tsx @@ -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(null); + + const { fetchNextPage, hasNextPage, data } = useInfiniteRecipeCommentQuery(Number(recipeId)); + useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); + + const comments = data.pages.flatMap((page) => page.comments); + + return ( +
+ + 댓글 ({comments.length}개) + + + {comments.map((comment) => ( + + ))} +
+
+ ); +}; + +export default CommentList; diff --git a/frontend/src/components/Recipe/index.ts b/frontend/src/components/Recipe/index.ts index f0ecdf5f6..b79761203 100644 --- a/frontend/src/components/Recipe/index.ts +++ b/frontend/src/components/Recipe/index.ts @@ -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'; diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx index 0bfdb0837..f14148646 100644 --- a/frontend/src/contexts/ToastContext.tsx +++ b/frontend/src/contexts/ToastContext.tsx @@ -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); `; diff --git a/frontend/src/hooks/queries/recipe/index.ts b/frontend/src/hooks/queries/recipe/index.ts index ef871dadd..0cd5db9f6 100644 --- a/frontend/src/hooks/queries/recipe/index.ts +++ b/frontend/src/hooks/queries/recipe/index.ts @@ -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'; diff --git a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts new file mode 100644 index 000000000..460b11e92 --- /dev/null +++ b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts @@ -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; diff --git a/frontend/src/hooks/queries/recipe/useRecipeCommentMutation.ts b/frontend/src/hooks/queries/recipe/useRecipeCommentMutation.ts new file mode 100644 index 000000000..fc599b15e --- /dev/null +++ b/frontend/src/hooks/queries/recipe/useRecipeCommentMutation.ts @@ -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; diff --git a/frontend/src/mocks/data/comments.json b/frontend/src/mocks/data/comments.json new file mode 100644 index 000000000..acf2f9b08 --- /dev/null +++ b/frontend/src/mocks/data/comments.json @@ -0,0 +1,32 @@ +{ + "hasNext": false, + "comments": [ + { + "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 + } + ] +} diff --git a/frontend/src/mocks/handlers/recipeHandlers.ts b/frontend/src/mocks/handlers/recipeHandlers.ts index 0a37b22f0..b213e5926 100644 --- a/frontend/src/mocks/handlers/recipeHandlers.ts +++ b/frontend/src/mocks/handlers/recipeHandlers.ts @@ -1,6 +1,7 @@ import { rest } from 'msw'; import { isRecipeSortOption, isSortOrder } from './utils'; +import comments from '../data/comments.json'; import recipeDetail from '../data/recipeDetail.json'; import mockRecipes from '../data/recipes.json'; @@ -88,4 +89,12 @@ export const recipeHandlers = [ ctx.json({ ...sortedRecipes, recipes: sortedRecipes.recipes.slice(page * 5, (page + 1) * 5) }) ); }), + + rest.get('/api/recipes/:recipeId/comments', (req, res, ctx) => { + return res(ctx.status(200), ctx.json(comments)); + }), + + rest.post('/api/recipes/:recipeId/comments', (req, res, ctx) => { + return res(ctx.status(201)); + }), ]; diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx index 61b1ab585..ed68acb7b 100644 --- a/frontend/src/pages/RecipeDetailPage.tsx +++ b/frontend/src/pages/RecipeDetailPage.tsx @@ -1,10 +1,12 @@ -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 { ErrorBoundary, ErrorComponent, Loading, SectionTitle } from '@/components/Common'; +import { CommentForm, CommentList, RecipeFavorite } from '@/components/Recipe'; import { useRecipeDetailQuery } from '@/hooks/queries/recipe'; import { getFormattedDate } from '@/utils/date'; @@ -12,10 +14,13 @@ export const RecipeDetailPage = () => { const { recipeId } = useParams(); const { data: recipeDetail } = useRecipeDetailQuery(Number(recipeId)); + + const { reset } = useQueryErrorResetBoundary(); + const { id, images, title, content, author, products, totalPrice, favoriteCount, favorite, createdAt } = recipeDetail; return ( - + <> {images.length > 0 ? ( @@ -65,15 +70,21 @@ export const RecipeDetailPage = () => { {content} - - + + + + + }> + + + + + + + ); }; -const RecipeDetailPageContainer = styled.div` - padding: 20px 20px 0; -`; - const RecipeImageContainer = styled.ul` display: flex; flex-direction: column; diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 0103d0fc7..1f0ee78a9 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -17,15 +17,6 @@ const router = createBrowserRouter([ ), errorElement: , children: [ - { - path: `${PATH.RECIPE}/:recipeId`, - async lazy() { - const { RecipeDetailPage } = await import( - /* webpackChunkName: "RecipeDetailPage" */ '@/pages/RecipeDetailPage' - ); - return { Component: RecipeDetailPage }; - }, - }, { path: PATH.MEMBER, async lazy() { @@ -119,6 +110,15 @@ const router = createBrowserRouter([ return { Component: ProductDetailPage }; }, }, + { + path: `${PATH.RECIPE}/:recipeId`, + async lazy() { + const { RecipeDetailPage } = await import( + /* webpackChunkName: "RecipeDetailPage" */ '@/pages/RecipeDetailPage' + ); + return { Component: RecipeDetailPage }; + }, + }, ], }, { diff --git a/frontend/src/types/recipe.ts b/frontend/src/types/recipe.ts index 3fec2ba91..da336d7b5 100644 --- a/frontend/src/types/recipe.ts +++ b/frontend/src/types/recipe.ts @@ -39,3 +39,10 @@ export interface RecipeFavoriteRequestBody { type RecipeProductWithPrice = Pick; export type RecipeProduct = Omit; + +export interface Comment { + id: number; + author: Member; + comment: string; + createdAt: string; +} diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index 7ab144bc0..fa17b469a 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -1,6 +1,6 @@ import type { Product } from './product'; import type { ProductRanking, RecipeRanking, ReviewRanking } from './ranking'; -import type { MemberRecipe, Recipe } from './recipe'; +import type { Comment, MemberRecipe, Recipe } from './recipe'; import type { Review } from './review'; import type { ProductSearchResult, ProductSearchAutocomplete } from './search'; @@ -63,3 +63,9 @@ export interface MemberRecipeResponse { page: Page; recipes: MemberRecipe[]; } + +export interface CommentResponse { + hasNext: boolean; + totalElements: number | null; + comments: Comment[]; +}