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[];
+}