diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 1461bde42..46ce41c44 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -119,6 +119,9 @@ d="M8 7a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3A.5.5 0 0 1 8 7zm0-.75a.749.749 0 1 0 0-1.5.749.749 0 0 0 0 1.498zM2 8a6 6 0 1 1 12 0A6 6 0 0 1 2 8zm6-5a5 5 0 1 0 0 10A5 5 0 0 0 8 3z" /> + + +
diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx index 0705d543a..e31256ae6 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -23,7 +23,8 @@ export const SVG_ICON_VARIANTS = [ 'camera', 'link', 'plane', - 'info' + 'info', + 'trashcan', ] 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 450c870be..e0fa06dd5 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -86,6 +86,9 @@ const SvgSprite = () => { + + + ); }; diff --git a/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx b/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx index 14558353f..740daddff 100644 --- a/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx +++ b/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx @@ -10,15 +10,15 @@ import { useInfiniteMemberRecipeQuery } from '@/hooks/queries/members'; import useDisplaySlice from '@/utils/displaySlice'; interface MemberRecipeListProps { - isMemberPage?: boolean; + isPreview?: boolean; } -const MemberRecipeList = ({ isMemberPage = false }: MemberRecipeListProps) => { +const MemberRecipeList = ({ isPreview = false }: MemberRecipeListProps) => { const scrollRef = useRef(null); const { fetchNextPage, hasNextPage, data } = useInfiniteMemberRecipeQuery(); const memberRecipes = data?.pages.flatMap((page) => page.recipes); - const recipeToDisplay = useDisplaySlice(isMemberPage, memberRecipes); + const recipeToDisplay = useDisplaySlice(isPreview, memberRecipes); useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); @@ -40,7 +40,7 @@ const MemberRecipeList = ({ isMemberPage = false }: MemberRecipeListProps) => { return ( - {!isMemberPage && ( + {!isPreview && ( {totalRecipeCount}개의 꿀조합을 남겼어요! @@ -50,7 +50,7 @@ const MemberRecipeList = ({ isMemberPage = false }: MemberRecipeListProps) => { {recipeToDisplay?.map((recipe) => (
  • - +
  • ))} diff --git a/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx new file mode 100644 index 000000000..89827a62c --- /dev/null +++ b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import MemberReviewItem from './MemberReviewItem'; + +import ToastProvider from '@/contexts/ToastContext'; + +const meta: Meta = { + title: 'members/MemberReviewItem', + component: MemberReviewItem, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + review: { + reviewId: 1, + productId: 5, + productName: '구운감자슬림명란마요', + content: + '할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데요 이것보다 긴 리뷰도 잘려 보인답니다', + rating: 4.0, + favoriteCount: 1256, + categoryType: 'food', + }, + isMemberPage: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx new file mode 100644 index 000000000..1d4503853 --- /dev/null +++ b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx @@ -0,0 +1,123 @@ +import { useTheme, Spacing, Text, Button } from '@fun-eat/design-system'; +import type { MouseEventHandler } from 'react'; +import styled from 'styled-components'; + +import { SvgIcon } from '@/components/Common'; +import { useToastActionContext } from '@/hooks/context'; +import { useDeleteReview } from '@/hooks/queries/members'; +import type { MemberReview } from '@/types/review'; + +interface MemberReviewItemProps { + review: MemberReview; + isPreview: boolean; +} + +const MemberReviewItem = ({ review, isPreview }: MemberReviewItemProps) => { + const theme = useTheme(); + + const { mutate } = useDeleteReview(); + + const { toast } = useToastActionContext(); + + const { reviewId, productName, content, rating, favoriteCount } = review; + + const handleReviewDelete: MouseEventHandler = (e) => { + e.preventDefault(); + + const result = window.confirm('리뷰를 삭제하시겠습니까?'); + if (!result) { + return; + } + + mutate(reviewId, { + onSuccess: () => { + toast.success('리뷰를 삭제했습니다.'); + }, + onError: (error) => { + if (error instanceof Error) { + toast.error(error.message); + return; + } + + toast.error('리뷰 좋아요를 다시 시도해주세요.'); + }, + }); + }; + + return ( + + + + {productName} + + {!isPreview && ( + + )} + + + {content} + + + + + + + {favoriteCount} + + + + + + {rating.toFixed(1)} + + + + + ); +}; + +export default MemberReviewItem; + +const ReviewRankingItemContainer = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 0; + border-bottom: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; +`; + +const ProductNameIconWrapper = styled.div` + display: flex; + justify-content: space-between; +`; + +const ReviewText = styled(Text)` + display: -webkit-inline-box; + text-overflow: ellipsis; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +const FavoriteStarWrapper = styled.div` + display: flex; + gap: 4px; +`; + +const FavoriteIconWrapper = styled.div` + display: flex; + gap: 4px; + align-items: center; +`; + +const RatingIconWrapper = styled.div` + display: flex; + gap: 2px; + align-items: center; + + & > svg { + padding-bottom: 2px; + } +`; diff --git a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx index fde211413..b622d9f65 100644 --- a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx +++ b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx @@ -3,21 +3,22 @@ import { useRef } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; -import { ReviewRankingItem } from '@/components/Rank'; +import MemberReviewItem from '../MemberReviewItem/MemberReviewItem'; + import { PATH } from '@/constants/path'; import { useIntersectionObserver } from '@/hooks/common'; import { useInfiniteMemberReviewQuery } from '@/hooks/queries/members'; import useDisplaySlice from '@/utils/displaySlice'; interface MemberReviewListProps { - isMemberPage?: boolean; + isPreview?: boolean; } -const MemberReviewList = ({ isMemberPage = false }: MemberReviewListProps) => { +const MemberReviewList = ({ isPreview = false }: MemberReviewListProps) => { const scrollRef = useRef(null); const { fetchNextPage, hasNextPage, data } = useInfiniteMemberReviewQuery(); const memberReviews = data.pages.flatMap((page) => page.reviews); - const reviewsToDisplay = useDisplaySlice(isMemberPage, memberReviews); + const reviewsToDisplay = useDisplaySlice(isPreview, memberReviews); useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); @@ -39,17 +40,17 @@ const MemberReviewList = ({ isMemberPage = false }: MemberReviewListProps) => { return ( - {!isMemberPage && ( + {!isPreview && ( {totalReviewCount}개의 리뷰를 남겼어요! )} - {reviewsToDisplay.map((reviewRanking) => ( -
  • - - + {reviewsToDisplay.map((review) => ( +
  • + +
  • ))} diff --git a/frontend/src/components/Members/index.ts b/frontend/src/components/Members/index.ts index a295e2728..4e31460ee 100644 --- a/frontend/src/components/Members/index.ts +++ b/frontend/src/components/Members/index.ts @@ -2,3 +2,4 @@ export { default as MembersInfo } from './MembersInfo/MembersInfo'; export { default as MemberReviewList } from './MemberReviewList/MemberReviewList'; export { default as MemberRecipeList } from './MemberRecipeList/MemberRecipeList'; export { default as MemberModifyInput } from './MemberModifyInput/MemberModifyInput'; +export { default as MemberReviewItem } from './MemberReviewItem/MemberReviewItem'; diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx index c2004c20e..ca2aed3dc 100644 --- a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx @@ -1,4 +1,4 @@ -import { Spacing, Text, theme } from '@fun-eat/design-system'; +import { Spacing, Text, useTheme } from '@fun-eat/design-system'; import { memo } from 'react'; import styled from 'styled-components'; @@ -7,14 +7,15 @@ import type { ReviewRanking } from '@/types/ranking'; interface ReviewRankingItemProps { reviewRanking: ReviewRanking; - isMemberPage?: boolean; } -const ReviewRankingItem = ({ reviewRanking, isMemberPage = false }: ReviewRankingItemProps) => { +const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { + const theme = useTheme(); + const { productName, content, rating, favoriteCount } = reviewRanking; return ( - + {productName} @@ -42,14 +43,13 @@ const ReviewRankingItem = ({ reviewRanking, isMemberPage = false }: ReviewRankin export default memo(ReviewRankingItem); -const ReviewRankingItemContainer = styled.div<{ isMemberPage: boolean }>` +const ReviewRankingItemContainer = styled.div` display: flex; flex-direction: column; gap: 4px; padding: 12px; - border: ${({ isMemberPage, theme }) => (isMemberPage ? 'none' : `1px solid ${theme.borderColors.disabled}`)}; - border-bottom: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; - border-radius: ${({ isMemberPage, theme }) => (isMemberPage ? 0 : theme.borderRadius.sm)}; + border: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; `; const ReviewText = styled(Text)` diff --git a/frontend/src/hooks/queries/members/index.ts b/frontend/src/hooks/queries/members/index.ts index 9e45e5239..cbd6e7468 100644 --- a/frontend/src/hooks/queries/members/index.ts +++ b/frontend/src/hooks/queries/members/index.ts @@ -3,3 +3,4 @@ export { default as useMemberQuery } from './useMemberQuery'; export { default as useInfiniteMemberRecipeQuery } from './useInfiniteMemberRecipeQuery'; export { default as useMemberModifyMutation } from './useMemberModifyMutation'; export { default as useLogoutMutation } from './useLogoutMutation'; +export { default as useDeleteReview } from './useDeleteReview'; diff --git a/frontend/src/hooks/queries/members/useDeleteReview.ts b/frontend/src/hooks/queries/members/useDeleteReview.ts new file mode 100644 index 000000000..a88169bce --- /dev/null +++ b/frontend/src/hooks/queries/members/useDeleteReview.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { memberApi } from '@/apis'; + +const headers = { 'Content-Type': 'application/json' }; + +const deleteReview = async (reviewId: number) => { + return memberApi.delete({ params: `/reviews/${reviewId}`, credentials: true }, headers); +}; + +const useDeleteReview = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (reviewId: number) => deleteReview(reviewId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['member', 'review'] }), + }); +}; + +export default useDeleteReview; diff --git a/frontend/src/mocks/handlers/memberHandlers.ts b/frontend/src/mocks/handlers/memberHandlers.ts index 2abb3d7fb..31ee93536 100644 --- a/frontend/src/mocks/handlers/memberHandlers.ts +++ b/frontend/src/mocks/handlers/memberHandlers.ts @@ -55,4 +55,8 @@ export const memberHandlers = [ return res(ctx.status(200), ctx.json(mockMemberRecipes)); }), + + rest.delete('/api/members/reviews/:reviewId', (req, res, ctx) => { + return res(ctx.status(204)); + }), ]; diff --git a/frontend/src/pages/MemberPage.tsx b/frontend/src/pages/MemberPage.tsx index 7026e4768..58aa6d8e4 100644 --- a/frontend/src/pages/MemberPage.tsx +++ b/frontend/src/pages/MemberPage.tsx @@ -20,14 +20,14 @@ export const MemberPage = () => { }> - + }> - + diff --git a/frontend/src/types/review.ts b/frontend/src/types/review.ts index ced1d2f58..dd0273ddf 100644 --- a/frontend/src/types/review.ts +++ b/frontend/src/types/review.ts @@ -20,6 +20,16 @@ export interface ReviewDetail extends Review { productName: string; } +export interface MemberReview { + reviewId: number; + productId: number; + productName: string; + content: string; + rating: number; + favoriteCount: number; + categoryType: CategoryVariant; +} + export interface ReviewTag { tagType: TagVariants; tags: Tag[];