From 3a950c204070dc74a059239768acd5f087f23adb Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Fri, 13 Oct 2023 14:59:24 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[FE]=20feat:=20=EA=BF=80=EC=A1=B0=ED=95=A9?= =?UTF-8?q?=20=EB=8C=93=EA=B8=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#744)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: CommentItem 컴포넌트 추가 * feat: 댓글 목록 가져오는 쿼리 추가 * feat: 상품 상세 페이지에 댓글 컴포넌트 추가 * feat: Input 컴포넌트 속성에 minWidth값 추가 * feat: CommentInput 컴포넌트 추가 * feat: 댓글 등록 기능 구현 * feat: 사용자가 입력한 글자수 UI 추가 * feat: 리뷰 반영 * feat: text area 텍스트 크기 수정 * feat: CommentList 컴포넌트 추가 * feat: 디자인 수정 * feat: api 변경 적용 * refactor: CommentInput -> CommentForm으로 네이밍 수정 * feat: data fetching 로직을 CommentList내부로 이동 * feat: 댓글 무한 스크롤로 변경 * fix: 토스트 컴포넌트가 가운데 정렬되지 않는 문제 해결 * feat: 전송 아이콘 추가 * feat: 댓글 컴포넌트를 fixed로 변경 * feat: 댓글 컴포넌트 사이 공백 추가 * feat: Response 객체에 totalElements 값 추가 * feat: pageParam의 기본값 추가 * feat: index.ts에서 export문 추가 --- frontend/.storybook/preview-body.html | 5 + .../src/components/Common/Input/Input.tsx | 11 +- .../src/components/Common/Svg/SvgIcon.tsx | 1 + .../src/components/Common/Svg/SvgSprite.tsx | 3 + .../src/components/Common/Toast/Toast.tsx | 2 +- .../CommentForm/CommentForm.stories.tsx | 13 +++ .../Recipe/CommentForm/CommentForm.tsx | 101 ++++++++++++++++++ .../CommentItem/CommentItem.stories.tsx | 18 ++++ .../Recipe/CommentItem/CommentItem.tsx | 50 +++++++++ .../CommentList/CommentList.stories.tsx | 13 +++ .../Recipe/CommentList/CommentList.tsx | 35 ++++++ frontend/src/components/Recipe/index.ts | 3 + frontend/src/contexts/ToastContext.tsx | 2 +- frontend/src/hooks/queries/recipe/index.ts | 2 + .../recipe/useInfiniteRecipeCommentQuery.ts | 36 +++++++ .../recipe/useRecipeCommentMutation.ts | 24 +++++ frontend/src/mocks/data/comments.json | 32 ++++++ frontend/src/mocks/handlers/recipeHandlers.ts | 9 ++ frontend/src/pages/RecipeDetailPage.tsx | 31 ++++-- frontend/src/router/index.tsx | 18 ++-- frontend/src/types/recipe.ts | 7 ++ frontend/src/types/response.ts | 8 +- 22 files changed, 399 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/Recipe/CommentForm/CommentForm.stories.tsx create mode 100644 frontend/src/components/Recipe/CommentForm/CommentForm.tsx create mode 100644 frontend/src/components/Recipe/CommentItem/CommentItem.stories.tsx create mode 100644 frontend/src/components/Recipe/CommentItem/CommentItem.tsx create mode 100644 frontend/src/components/Recipe/CommentList/CommentList.stories.tsx create mode 100644 frontend/src/components/Recipe/CommentList/CommentList.tsx create mode 100644 frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts create mode 100644 frontend/src/hooks/queries/recipe/useRecipeCommentMutation.ts create mode 100644 frontend/src/mocks/data/comments.json 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[]; +} From 679ee07de7b1d3c7faaaf3515c85f752b70658db Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Fri, 13 Oct 2023 16:30:16 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[FE]=20feat:=20=EB=9D=BC=EC=9A=B0=ED=8C=85?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EC=B2=98=EB=A6=AC=20(#752)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 최근 상품 상세 페이지 로컬스토리지에 저장 * fix: 로그아웃 후 마이페이지 방문 시 에러 처리 * refactor: 로컬스토리지 로직 유틸로 이동 --- frontend/src/constants/index.ts | 8 +++++- frontend/src/hooks/common/index.ts | 1 - frontend/src/pages/AuthPage.tsx | 16 ++++++++---- frontend/src/pages/ProductDetailPage.tsx | 31 +++++++++++++++++------- frontend/src/router/index.tsx | 4 +-- frontend/src/utils/localStorage.ts | 26 ++++++++++++++++++++ 6 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 frontend/src/utils/localStorage.ts 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/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/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index a840cb978..e5f60c954 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 f41985856..82c8ce4e0 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/router/index.tsx b/frontend/src/router/index.tsx index 1f0ee78a9..739785556 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,7 +15,7 @@ const router = createBrowserRouter([ ), - errorElement: , + errorElement: , children: [ { path: PATH.MEMBER, 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); +}; From 43cf12a3130a921ebf6b917d2d275c461ca369be Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Fri, 13 Oct 2023 16:39:43 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[FE]=20fix:=20=EB=A1=9C=EC=BB=AC=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=EC=A7=80=20=EC=9C=A0=ED=8B=B8=20import=20?= =?UTF-8?q?=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90=20=EC=88=98=EC=A0=95=20(#7?= =?UTF-8?q?60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/AuthPage.tsx | 2 +- frontend/src/pages/ProductDetailPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index e5f60c954..579bca327 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -5,7 +5,7 @@ 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'; +import { getLocalStorage, removeLocalStorage } from '@/utils/localStorage'; export const AuthPage = () => { const { authProvider } = useParams(); diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index 82c8ce4e0..12df226e0 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -23,7 +23,7 @@ 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'; +import { setLocalStorage } from '@/utils/localStorage'; const LOGIN_ERROR_MESSAGE_REVIEW = '로그인 후 상품 리뷰를 볼 수 있어요.\n펀잇에 가입하고 편의점 상품 리뷰를 확인해보세요 😊';