diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 4c6e6e33b..9e1c31c57 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -109,6 +109,10 @@ d="M14.485 9.47a.75.75 0 0 0-1.063 1.06c1.164 1.168 1.133 3.279-.303 4.72l-4.847 4.866c-1.435 1.44-3.533 1.47-4.694.304c-1.164-1.168-1.132-3.28.303-4.72l2.424-2.433a.75.75 0 0 0-1.063-1.059l-2.424 2.433c-1.911 1.92-2.151 4.982-.303 6.838c1.85 1.858 4.907 1.615 6.82-.304l4.847-4.867c1.911-1.918 2.151-4.982.303-6.837Z" > + + 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 13a2aa1f5..b8be9a974 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -22,6 +22,7 @@ export const SVG_ICON_VARIANTS = [ 'pencil', 'camera', 'link', + '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 ec2c8f2e0..65cf49d3d 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -80,6 +80,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/constants/index.ts b/frontend/src/constants/index.ts index 81b964c6c..22c296123 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -63,7 +63,13 @@ export const CATEGORY_TYPE = { export const IMAGE_MAX_SIZE = 5 * 1024 * 1024; -export const ENVIRONMENT = window.location.href.includes('dev') ? 'dev' : 'prod'; +export const ENVIRONMENT = window.location.href.includes('dev') + ? 'dev' + : process.env.NODE_ENV === 'production' + ? 'prod' + : 'local'; export const IMAGE_URL = ENVIRONMENT === 'dev' ? process.env.S3_DEV_CLOUDFRONT_PATH : process.env.S3_PROD_CLOUDFRONT_PATH; + +export const PRODUCT_PATH_LOCAL_STORAGE_KEY = `funeat-last-product-path-${ENVIRONMENT}`; 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/common/index.ts b/frontend/src/hooks/common/index.ts index 61c985f67..199ae608a 100644 --- a/frontend/src/hooks/common/index.ts +++ b/frontend/src/hooks/common/index.ts @@ -12,4 +12,3 @@ export { default as useTabMenu } from './useTabMenu'; export { default as useScrollRestoration } from './useScrollRestoration'; export { default as useToast } from './useToast'; export { default as useGA } from './useGA'; - 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/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index a840cb978..579bca327 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { loginApi } from '@/apis'; +import { PRODUCT_PATH_LOCAL_STORAGE_KEY } from '@/constants'; import { PATH } from '@/constants/path'; import { useMemberQuery } from '@/hooks/queries/members'; +import { getLocalStorage, removeLocalStorage } from '@/utils/localStorage'; export const AuthPage = () => { const { authProvider } = useParams(); @@ -14,10 +16,6 @@ export const AuthPage = () => { const [location, setLocation] = useState(''); const navigate = useNavigate(); - if (member) { - return ; - } - const getSessionId = async () => { const response = await loginApi.get({ params: `/oauth2/code/${authProvider}`, @@ -51,9 +49,17 @@ export const AuthPage = () => { return; } + const productPath = getLocalStorage(PRODUCT_PATH_LOCAL_STORAGE_KEY); + const redirectLocation = productPath ? productPath : location; + + navigate(redirectLocation, { replace: true }); + removeLocalStorage(PRODUCT_PATH_LOCAL_STORAGE_KEY); refetchMember(); - navigate(location, { replace: true }); }, [location]); + if (member) { + return ; + } + return <>>; }; diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index 3ab92e45f..f53b61282 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -1,7 +1,7 @@ -import { BottomSheet, Spacing, useBottomSheet, Text, Link } from '@fun-eat/design-system'; +import { BottomSheet, Spacing, useBottomSheet, Text, Button } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; import { useState, useRef, Suspense } from 'react'; -import { useParams, Link as RouterLink } from 'react-router-dom'; +import { useParams, useLocation, useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { @@ -17,12 +17,13 @@ import { } from '@/components/Common'; import { ProductDetailItem, ProductRecipeList } from '@/components/Product'; import { BestReviewItem, ReviewList, ReviewRegisterForm } from '@/components/Review'; -import { RECIPE_SORT_OPTIONS, REVIEW_SORT_OPTIONS } from '@/constants'; +import { PRODUCT_PATH_LOCAL_STORAGE_KEY, RECIPE_SORT_OPTIONS, REVIEW_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; import ReviewFormProvider from '@/contexts/ReviewFormContext'; import { useGA, useSortOption, useTabMenu } from '@/hooks/common'; import { useMemberQuery } from '@/hooks/queries/members'; import { useProductDetailQuery } from '@/hooks/queries/product'; +import { setLocalStorage } from '@/utils/localStorage'; const LOGIN_ERROR_MESSAGE_REVIEW = '로그인 후 상품 리뷰를 볼 수 있어요.\n펀잇에 가입하고 편의점 상품 리뷰를 확인해보세요 😊'; @@ -31,6 +32,9 @@ const LOGIN_ERROR_MESSAGE_RECIPE = export const ProductDetailPage = () => { const { category, productId } = useParams(); + const { pathname } = useLocation(); + const navigate = useNavigate(); + const { data: member } = useMemberQuery(); const { data: productDetail } = useProductDetailQuery(Number(productId)); @@ -46,7 +50,7 @@ export const ProductDetailPage = () => { const productDetailPageRef = useRef(null); - if (!category) { + if (!category || !productId) { return null; } @@ -73,6 +77,11 @@ export const ProductDetailPage = () => { selectSortOption(currentSortOption); }; + const handleLoginButtonClick = () => { + setLocalStorage(PRODUCT_PATH_LOCAL_STORAGE_KEY, pathname); + navigate(PATH.LOGIN); + }; + return ( @@ -107,9 +116,15 @@ export const ProductDetailPage = () => { {isReviewTab ? LOGIN_ERROR_MESSAGE_REVIEW : LOGIN_ERROR_MESSAGE_RECIPE} - + 로그인하러 가기 - + )} @@ -171,10 +186,8 @@ const ErrorDescription = styled(Text)` white-space: pre-wrap; `; -const LoginLink = styled(Link)` - padding: 16px 24px; +const LoginButton = styled(Button)` border: 1px solid ${({ theme }) => theme.colors.gray4}; - border-radius: 8px; `; const ReviewRegisterButtonWrapper = styled.div` 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 44cb6b9a8..d045e8dc3 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,4 +1,4 @@ -import { createBrowserRouter } from 'react-router-dom'; +import { Navigate, createBrowserRouter } from 'react-router-dom'; import App from './App'; @@ -15,17 +15,8 @@ const router = createBrowserRouter([ ), - errorElement: , + errorElement: , children: [ - { - path: `${PATH.RECIPE}/:recipeId`, - async lazy() { - const { RecipeDetailPage } = await import( - /* webpackChunkName: "RecipeDetailPage" */ '@/pages/RecipeDetailPage' - ); - return { Component: RecipeDetailPage }; - }, - }, { path: PATH.MEMBER, async lazy() { @@ -128,6 +119,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 6cfa8dd50..42c21a24d 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, ReviewDetail } from './review'; import type { ProductSearchResult, ProductSearchAutocomplete } from './search'; @@ -63,7 +63,11 @@ export interface MemberRecipeResponse { page: Page; recipes: MemberRecipe[]; } - export interface ReviewDetailResponse { reviews: ReviewDetail; } +export interface CommentResponse { + hasNext: boolean; + totalElements: number | null; + comments: Comment[]; +} diff --git a/frontend/src/utils/localStorage.ts b/frontend/src/utils/localStorage.ts new file mode 100644 index 000000000..91ce0c762 --- /dev/null +++ b/frontend/src/utils/localStorage.ts @@ -0,0 +1,26 @@ +export const getLocalStorage = (key: string) => { + const item = localStorage.getItem(key); + + if (item) { + try { + return JSON.parse(item); + } catch (error) { + return item; + } + } + + return null; +}; + +export const setLocalStorage = (key: string, newValue: unknown) => { + if (typeof newValue === 'string') { + localStorage.setItem(key, newValue); + return; + } + + localStorage.setItem(key, JSON.stringify(newValue)); +}; + +export const removeLocalStorage = (key: string) => { + localStorage.removeItem(key); +};