Skip to content

Commit

Permalink
[FE] feat: 리뷰, 꿀조합 좋아요에 낙관적 업데이트 적용 (#839)
Browse files Browse the repository at this point in the history
* feat: 리뷰 좋아요에 낙관적 업데이트 적용

* feat: 리뷰 좋아요 버튼 컴포넌트 분리

* feat: 꿀조합 좋아요에 낙관적 업데이트 적용

* feat: client state로 좋아요를 관리하게끔 수정

* feat: 꿀조합 좋아요는 server state를 동기화하도록 수정

* style: console.log 제거

* feat: 토스트 에러 메시지 수정

* feat: 좋아요 개수도 업데이트

* fix: 객체 destructing이 안되어 수정
  • Loading branch information
xodms0309 authored Nov 15, 2023
1 parent a0793f3 commit b88ee37
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 118 deletions.
60 changes: 0 additions & 60 deletions frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { theme, Button, Text } from '@fun-eat/design-system';
import styled from 'styled-components';

import { SvgIcon } from '@/components/Common';
import { useTimeout } from '@/hooks/common';
import { useRecipeFavoriteMutation } from '@/hooks/queries/recipe';

interface RecipeFavoriteProps {
favorite: boolean;
favoriteCount: number;
recipeId: number;
}

const RecipeFavoriteButton = ({ recipeId, favorite, favoriteCount }: RecipeFavoriteProps) => {
const { mutate } = useRecipeFavoriteMutation(Number(recipeId));

const handleToggleFavorite = async () => {
mutate({ favorite: !favorite });
};

const [debouncedToggleFavorite] = useTimeout(handleToggleFavorite, 200);

return (
<FavoriteButton type="button" variant="transparent" onClick={debouncedToggleFavorite}>
<SvgIcon
variant={favorite ? 'favoriteFilled' : 'favorite'}
color={favorite ? 'red' : theme.colors.gray4}
width={18}
/>
<Text weight="bold" size="lg">
{favoriteCount}
</Text>
</FavoriteButton>
);
};

export default RecipeFavoriteButton;

const FavoriteButton = styled(Button)`
display: flex;
gap: 8px;
align-items: center;
`;
2 changes: 1 addition & 1 deletion frontend/src/components/Recipe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export { default as RecipeUsedProducts } from './RecipeUsedProducts/RecipeUsedPr
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 RecipeFavoriteButton } from './RecipeFavoriteButton/RecipeFavoriteButton';
export { default as CommentItem } from './CommentItem/CommentItem';
export { default as CommentForm } from './CommentForm/CommentForm';
export { default as CommentList } from './CommentList/CommentList';
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Text, Button, useTheme } from '@fun-eat/design-system';
import { useState } from 'react';
import styled from 'styled-components';

import { SvgIcon } from '@/components/Common';
import { useTimeout } from '@/hooks/common';
import { useReviewFavoriteMutation } from '@/hooks/queries/review';

interface ReviewFavoriteButtonProps {
productId: number;
reviewId: number;
favorite: boolean;
favoriteCount: number;
}

const ReviewFavoriteButton = ({ productId, reviewId, favorite, favoriteCount }: ReviewFavoriteButtonProps) => {
const theme = useTheme();

const initialFavoriteState = {
isFavorite: favorite,
currentFavoriteCount: favoriteCount,
};

const [favoriteInfo, setFavoriteInfo] = useState(initialFavoriteState);
const { isFavorite, currentFavoriteCount } = favoriteInfo;

const { mutate } = useReviewFavoriteMutation(productId, reviewId);

const handleToggleFavorite = async () => {
setFavoriteInfo((prev) => ({
isFavorite: !prev.isFavorite,
currentFavoriteCount: isFavorite ? prev.currentFavoriteCount - 1 : prev.currentFavoriteCount + 1,
}));

mutate(
{ favorite: !isFavorite },
{
onError: () => {
setFavoriteInfo(initialFavoriteState);
},
}
);
};

const [debouncedToggleFavorite] = useTimeout(handleToggleFavorite, 200);

return (
<FavoriteButton
type="button"
variant="transparent"
onClick={debouncedToggleFavorite}
aria-label={`좋아요 ${currentFavoriteCount}개`}
>
<SvgIcon variant={isFavorite ? 'favoriteFilled' : 'favorite'} color={isFavorite ? 'red' : theme.colors.gray4} />
<Text as="span" weight="bold">
{currentFavoriteCount}
</Text>
</FavoriteButton>
);
};

export default ReviewFavoriteButton;

const FavoriteButton = styled(Button)`
display: flex;
align-items: center;
padding: 0;
column-gap: 8px;
`;
59 changes: 7 additions & 52 deletions frontend/src/components/Review/ReviewItem/ReviewItem.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Badge, Button, Text, useTheme } from '@fun-eat/design-system';
import { memo, useState } from 'react';
import { Badge, Text, useTheme } from '@fun-eat/design-system';
import { memo } from 'react';
import styled from 'styled-components';

import ReviewFavoriteButton from '../ReviewFavoriteButton/ReviewFavoriteButton';

import { SvgIcon, TagList } from '@/components/Common';
import { useTimeout } from '@/hooks/common';
import { useToastActionContext } from '@/hooks/context';
import { useReviewFavoriteMutation } from '@/hooks/queries/review';
import type { Review } from '@/types/review';
import { getRelativeDate } from '@/utils/date';

Expand All @@ -15,37 +14,10 @@ interface ReviewItemProps {
}

const ReviewItem = ({ productId, review }: ReviewItemProps) => {
const { id, userName, profileImage, image, rating, tags, content, createdAt, rebuy, favoriteCount, favorite } =
review;
const [isFavorite, setIsFavorite] = useState(favorite);
const [currentFavoriteCount, setCurrentFavoriteCount] = useState(favoriteCount);

const { toast } = useToastActionContext();
const { mutate } = useReviewFavoriteMutation(productId, id);

const theme = useTheme();

const handleToggleFavorite = async () => {
mutate(
{ favorite: !isFavorite },
{
onSuccess: () => {
setIsFavorite((prev) => !prev);
setCurrentFavoriteCount((prev) => (isFavorite ? prev - 1 : prev + 1));
},
onError: (error) => {
if (error instanceof Error) {
toast.error(error.message);
return;
}

toast.error('리뷰 좋아요를 다시 시도해주세요.');
},
}
);
};

const [debouncedToggleFavorite] = useTimeout(handleToggleFavorite, 200);
const { id, userName, profileImage, image, rating, tags, content, createdAt, rebuy, favorite, favoriteCount } =
review;

return (
<ReviewItemContainer>
Expand Down Expand Up @@ -79,17 +51,7 @@ const ReviewItem = ({ productId, review }: ReviewItemProps) => {
{image && <ReviewImage src={image} height={150} alt={`${userName}의 리뷰`} />}
<TagList tags={tags} />
<ReviewContent>{content}</ReviewContent>
<FavoriteButton
type="button"
variant="transparent"
onClick={debouncedToggleFavorite}
aria-label={`좋아요 ${favoriteCount}개`}
>
<SvgIcon variant={isFavorite ? 'favoriteFilled' : 'favorite'} color={isFavorite ? 'red' : theme.colors.gray4} />
<Text as="span" weight="bold">
{currentFavoriteCount}
</Text>
</FavoriteButton>
<ReviewFavoriteButton productId={productId} reviewId={id} favorite={favorite} favoriteCount={favoriteCount} />
</ReviewItemContainer>
);
};
Expand Down Expand Up @@ -141,10 +103,3 @@ const ReviewImage = styled.img`
const ReviewContent = styled(Text)`
white-space: pre-wrap;
`;

const FavoriteButton = styled(Button)`
display: flex;
align-items: center;
padding: 0;
column-gap: 8px;
`;
1 change: 1 addition & 0 deletions frontend/src/components/Review/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { default as ReviewTagItem } from './ReviewTagItem/ReviewTagItem';
export { default as ReviewTagList } from './ReviewTagList/ReviewTagList';
export { default as ReviewRegisterForm } from './ReviewRegisterForm/ReviewRegisterForm';
export { default as BestReviewItem } from './BestReviewItem/BestReviewItem';
export { default as ReviewFavoriteButton } from './ReviewFavoriteButton/ReviewFavoriteButton';
37 changes: 35 additions & 2 deletions frontend/src/hooks/queries/recipe/useRecipeFavoriteMutation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { recipeApi } from '@/apis';
import type { RecipeFavoriteRequestBody } from '@/types/recipe';
import { useToastActionContext } from '@/hooks/context';
import type { RecipeFavoriteRequestBody, RecipeDetail } from '@/types/recipe';

const headers = { 'Content-Type': 'application/json' };

Expand All @@ -11,10 +12,42 @@ const patchRecipeFavorite = (recipeId: number, body: RecipeFavoriteRequestBody)

const useRecipeFavoriteMutation = (recipeId: number) => {
const queryClient = useQueryClient();
const { toast } = useToastActionContext();

const queryKey = ['recipeDetail', recipeId];

return useMutation({
mutationFn: (body: RecipeFavoriteRequestBody) => patchRecipeFavorite(recipeId, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recipeDetail', recipeId] }),
onMutate: async (newFavoriteRequest) => {
await queryClient.cancelQueries({ queryKey: queryKey });

const previousRequest = queryClient.getQueryData<RecipeDetail>(queryKey);

if (previousRequest) {
queryClient.setQueryData(queryKey, () => ({
...previousRequest,
favorite: newFavoriteRequest.favorite,
favoriteCount: newFavoriteRequest.favorite
? previousRequest.favoriteCount + 1
: previousRequest.favoriteCount - 1,
}));
}

return { previousRequest };
},
onError: (error, _, context) => {
queryClient.setQueryData(queryKey, context?.previousRequest);

if (error instanceof Error) {
toast.error(error.message);
return;
}

toast.error('좋아요를 다시 시도해주세요.');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKey });
},
});
};

Expand Down
14 changes: 13 additions & 1 deletion frontend/src/hooks/queries/review/useReviewFavoriteMutation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { productApi } from '@/apis';
import { useToastActionContext } from '@/hooks/context';
import type { ReviewFavoriteRequestBody } from '@/types/review';

const headers = { 'Content-Type': 'application/json' };
Expand All @@ -11,10 +12,21 @@ const patchReviewFavorite = (productId: number, reviewId: number, body: ReviewFa

const useReviewFavoriteMutation = (productId: number, reviewId: number) => {
const queryClient = useQueryClient();
const { toast } = useToastActionContext();

const queryKey = ['product', productId, 'review'];

return useMutation({
mutationFn: (body: ReviewFavoriteRequestBody) => patchReviewFavorite(productId, reviewId, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['product', productId, 'review'] }),
onError: (error) => {
if (error instanceof Error) {
toast.error(error.message);
return;
}

toast.error('좋아요를 다시 시도해주세요.');
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKey }),
});
};

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/RecipeDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import styled from 'styled-components';

import RecipePreviewImage from '@/assets/plate.svg';
import { ErrorBoundary, ErrorComponent, Loading, SectionTitle } from '@/components/Common';
import { CommentForm, CommentList, RecipeFavorite } from '@/components/Recipe';
import { CommentForm, CommentList, RecipeFavoriteButton } from '@/components/Recipe';
import { useRecipeDetailQuery } from '@/hooks/queries/recipe';
import { getFormattedDate } from '@/utils/date';

Expand Down Expand Up @@ -46,7 +46,7 @@ export const RecipeDetailPage = () => {
<Text color={theme.textColors.info}> {getFormattedDate(createdAt)}</Text>
</div>
</AuthorWrapper>
<RecipeFavorite recipeId={id} favorite={favorite} favoriteCount={favoriteCount} />
<RecipeFavoriteButton recipeId={id} favorite={favorite} favoriteCount={favoriteCount} />
</AuthorFavoriteWrapper>
<Spacing size={24} />
<RecipeUsedProductsWrapper>
Expand Down

0 comments on commit b88ee37

Please sign in to comment.