diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html index f1751ae4d..47e09cd47 100644 --- a/.storybook/preview-body.html +++ b/.storybook/preview-body.html @@ -409,4 +409,5 @@
+
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 944ee1c06..aa93f21e0 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -13,6 +13,7 @@ import { } from '../src/mocks/handlers'; import { BrowserRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import ToastProvider from '../src/components/Common/Toast/context/ToastContext'; initialize({ serviceWorker: { @@ -27,7 +28,9 @@ export const decorators = [ - + + + diff --git a/public/index.html b/public/index.html index 6abbc8f7b..a1ed5fded 100644 --- a/public/index.html +++ b/public/index.html @@ -32,6 +32,7 @@ /> +
diff --git a/src/components/Common/ImageUploader/ImageUploader.tsx b/src/components/Common/ImageUploader/ImageUploader.tsx index 60b7b14de..2b2c2ac23 100644 --- a/src/components/Common/ImageUploader/ImageUploader.tsx +++ b/src/components/Common/ImageUploader/ImageUploader.tsx @@ -1,8 +1,8 @@ -import { useToastActionContext } from '@fun-eat/design-system'; import type { ChangeEventHandler } from 'react'; import { container, deleteButton, image, imageWrapper, uploadInput, uploadLabel } from './imageUploader.css'; import SvgIcon from '../Svg/SvgIcon'; +import { useToastActionContext } from '../Toast/context'; import Text from '../Typography/Text/Text'; import { IMAGE_MAX_SIZE } from '@/constants'; diff --git a/src/components/Common/Toast/Toast.stories.tsx b/src/components/Common/Toast/Toast.stories.tsx new file mode 100644 index 000000000..5b58031aa --- /dev/null +++ b/src/components/Common/Toast/Toast.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { useToastActionContext } from './context'; +import ToastProvider from './context/ToastContext'; +import Toast from './Toast'; + +const meta: Meta = { + title: 'common/Toast', + component: Toast, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const { toast } = useToastActionContext(); + const handleClick = () => { + toast.success('성공'); + }; + return ( +
+ +
+ ); + }, +}; + +export const Error: Story = { + render: () => { + const { toast } = useToastActionContext(); + const handleClick = () => { + toast.error('실패'); + }; + return ( +
+ +
+ ); + }, +}; diff --git a/src/components/Common/Toast/Toast.tsx b/src/components/Common/Toast/Toast.tsx new file mode 100644 index 000000000..a24e62127 --- /dev/null +++ b/src/components/Common/Toast/Toast.tsx @@ -0,0 +1,25 @@ +import cx from 'classnames'; + +import { wrapper, toastMessage } from './toast.css'; +import useToast from './useToast'; +import Text from '../Typography/Text/Text'; + +export interface ToastProps { + id: number; + message: string; + isError?: boolean; +} + +const Toast = ({ id, message, isError = false }: ToastProps) => { + const isShown = useToast(id); + + return ( +
+ + {message} + +
+ ); +}; + +export default Toast; diff --git a/src/components/Common/Toast/context/ToastContext.tsx b/src/components/Common/Toast/context/ToastContext.tsx new file mode 100644 index 000000000..f278d933f --- /dev/null +++ b/src/components/Common/Toast/context/ToastContext.tsx @@ -0,0 +1,71 @@ +import type { PropsWithChildren } from 'react'; +import { createContext, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import Toast from '../Toast'; +import { container } from '../toast.css'; + +export interface ToastState { + id: number; + message: string; + isError?: boolean; +} + +export interface ToastValue { + toasts: ToastState[]; +} + +export interface ToastAction { + toast: { + success: (message: string) => void; + error: (message: string) => void; + }; + deleteToast: (id: number) => void; +} + +export const ToastValueContext = createContext(null); +export const ToastActionContext = createContext(null); + +export const ToastProvider = ({ children }: PropsWithChildren) => { + const [toasts, setToasts] = useState([]); + + const showToast = (id: number, message: string, isError?: boolean) => { + setToasts([...toasts, { id, message, isError }]); + }; + + const deleteToast = (id: number) => { + setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id)); + }; + + const toast = { + success: (message: string) => showToast(Number(Date.now()), message), + error: (message: string) => showToast(Number(Date.now()), message, true), + }; + + const toastValue = { + toasts, + }; + + const toastAction = { + toast, + deleteToast, + }; + + return ( + + + {children} + {createPortal( +
+ {toasts.map(({ id, message, isError }) => ( + + ))} +
, + document.getElementById('toast-container-root') as HTMLElement + )} +
+
+ ); +}; + +export default ToastProvider; diff --git a/src/components/Common/Toast/context/index.ts b/src/components/Common/Toast/context/index.ts new file mode 100644 index 000000000..95fdb8e5e --- /dev/null +++ b/src/components/Common/Toast/context/index.ts @@ -0,0 +1,3 @@ +export { default as useToastActionContext } from './useToastActionContext'; +export { default as useToastValueContext } from './useToastValueContext'; +export { default as ToastContext } from './ToastContext'; diff --git a/src/components/Common/Toast/context/useToastActionContext.ts b/src/components/Common/Toast/context/useToastActionContext.ts new file mode 100644 index 000000000..3c1764782 --- /dev/null +++ b/src/components/Common/Toast/context/useToastActionContext.ts @@ -0,0 +1,14 @@ +import { useContext } from 'react'; + +import { ToastActionContext } from './ToastContext'; + +export const useToastActionContext = () => { + const toastAction = useContext(ToastActionContext); + if (!toastAction) { + throw new Error('useToastActionContext는 Toast Provider 안에서 사용해야 합니다.'); + } + + return toastAction; +}; + +export default useToastActionContext; diff --git a/src/components/Common/Toast/context/useToastValueContext.ts b/src/components/Common/Toast/context/useToastValueContext.ts new file mode 100644 index 000000000..7413f8845 --- /dev/null +++ b/src/components/Common/Toast/context/useToastValueContext.ts @@ -0,0 +1,14 @@ +import { useContext } from 'react'; + +import { ToastValueContext } from './ToastContext'; + +export const useToastValueContext = () => { + const toastValue = useContext(ToastValueContext); + if (!toastValue) { + throw new Error('useToastValueContext는 Toast Provider 안에서 사용해야 합니다.'); + } + + return toastValue; +}; + +export default useToastValueContext; diff --git a/src/components/Common/Toast/toast.css.ts b/src/components/Common/Toast/toast.css.ts new file mode 100644 index 000000000..f1be1ac18 --- /dev/null +++ b/src/components/Common/Toast/toast.css.ts @@ -0,0 +1,62 @@ +import { vars } from '@/styles/theme.css'; +import { keyframes, style } from '@vanilla-extract/css'; + +const fadeOut = keyframes({ + '0%': { + transform: 'translateY(70px)', + opacity: 1, + }, + '100%': { + transform: 'translateY(70px)', + opacity: 0, + }, +}); + +const slideIn = keyframes({ + '0%': { + transform: 'translateY(-100px)', + }, + '100%': { + transform: 'translateY(70px)', + }, +}); + +export const container = style({ + position: 'fixed', + zIndex: 1000, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + width: '100%', + transform: 'translate(0, -10px)', +}); + +export const wrapper = style({ + display: 'flex', + alignItems: 'center', + position: 'relative', + width: 'calc(100% - 20px)', + height: 55, + maxWidth: 560, + borderRadius: 10, + background: vars.colors.black, + selectors: { + '&.isError': { + backgroundColor: vars.colors.error, + }, + '&.isShown': { + animation: `${slideIn} 0.3s ease-in-out forwards`, + }, + '&:not(.isShown)': { + animation: `${fadeOut} 0.3s ease-in-out forwards`, + }, + }, +}); + +export const isError = style({ + background: vars.colors.error, +}); + +export const toastMessage = style({ + marginLeft: 20, +}); diff --git a/src/components/Common/Toast/useToast.ts b/src/components/Common/Toast/useToast.ts new file mode 100644 index 000000000..dba923d41 --- /dev/null +++ b/src/components/Common/Toast/useToast.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react'; + +import { useToastActionContext } from './context'; + +const useToast = (id: number) => { + const { deleteToast } = useToastActionContext(); + const [isShown, setIsShown] = useState(true); + + const showTimeoutRef = useRef(null); + const deleteTimeoutRef = useRef(null); + + useEffect(() => { + showTimeoutRef.current = window.setTimeout(() => setIsShown(false), 2000); + + return () => { + if (showTimeoutRef.current) { + clearTimeout(showTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + if (!isShown) { + deleteTimeoutRef.current = window.setTimeout(() => deleteToast(id), 2000); + } + + return () => { + if (deleteTimeoutRef.current) { + clearTimeout(deleteTimeoutRef.current); + } + }; + }, [isShown]); + + return isShown; +}; + +export default useToast; diff --git a/src/components/Members/MemberReviewItem/MemberReviewItem.tsx b/src/components/Members/MemberReviewItem/MemberReviewItem.tsx index 52efee7b1..cf4263662 100644 --- a/src/components/Members/MemberReviewItem/MemberReviewItem.tsx +++ b/src/components/Members/MemberReviewItem/MemberReviewItem.tsx @@ -1,10 +1,10 @@ -import { useToastActionContext } from '@fun-eat/design-system'; import type { MouseEventHandler } from 'react'; import { Link } from 'react-router-dom'; import { titleWrapper } from './memberReviewItem.css'; import { SvgIcon, Text } from '@/components/Common'; +import { useToastActionContext } from '@/components/Common/Toast/context'; import { ReviewItemInfo } from '@/components/Review'; import { PATH } from '@/constants/path'; import { useDeleteReview } from '@/hooks/queries/members'; @@ -40,7 +40,7 @@ const MemberReviewItem = ({ review }: MemberReviewItemProps) => { return; } - toast.error('리뷰 좋아요를 다시 시도해주세요.'); + toast.error('리뷰 삭제를 다시 시도해주세요.'); }, }); }; diff --git a/src/components/Recipe/CommentForm/CommentForm.tsx b/src/components/Recipe/CommentForm/CommentForm.tsx index 24fc6ffa6..ed89b69a2 100644 --- a/src/components/Recipe/CommentForm/CommentForm.tsx +++ b/src/components/Recipe/CommentForm/CommentForm.tsx @@ -1,10 +1,10 @@ -import { useToastActionContext } from '@fun-eat/design-system'; import type { ChangeEventHandler, FormEventHandler, RefObject } from 'react'; import { useRef, useState } from 'react'; import { commentForm, commentTextarea, container, sendButton } from './commentForm.css'; import { SvgIcon, Text } from '@/components/Common'; +import { useToastActionContext } from '@/components/Common/Toast/context'; import { MemberImage } from '@/components/Members'; import { useScroll } from '@/hooks/common'; import { useMemberQuery } from '@/hooks/queries/members'; diff --git a/src/components/Recipe/RecipeFavoriteButton/RecipeFavoriteButton.tsx b/src/components/Recipe/RecipeFavoriteButton/RecipeFavoriteButton.tsx index de77e4774..71f7fc117 100644 --- a/src/components/Recipe/RecipeFavoriteButton/RecipeFavoriteButton.tsx +++ b/src/components/Recipe/RecipeFavoriteButton/RecipeFavoriteButton.tsx @@ -2,6 +2,7 @@ import { container, countWrapper } from './recipeFavoriteButton.css'; import HeartEmpty from '@/assets/heart-empty.png'; import { SvgIcon, Text } from '@/components/Common'; +import { useToastActionContext } from '@/components/Common/Toast/context'; import { useTimeout } from '@/hooks/common'; import { useMemberQuery } from '@/hooks/queries/members'; import { useRecipeBookmarkMutation, useRecipeFavoriteMutation } from '@/hooks/queries/recipe'; @@ -17,9 +18,37 @@ const RecipeFavoriteButton = ({ recipeId, favorite, favoriteCount }: RecipeFavor const { mutate: bookmarkMutate } = useRecipeBookmarkMutation(Number(recipeId)); const { data: member } = useMemberQuery(); + const { toast } = useToastActionContext(); + const handleToggleFavorite = async () => { - favoriteMutate({ favorite: !favorite }); - bookmarkMutate({ bookmark: !favorite }); + favoriteMutate( + { favorite: !favorite }, + { + onError: (error) => { + console.log(error); + if (error instanceof Error) { + toast.error(error.message); + return; + } + + toast.error('좋아요를 다시 시도해주세요.'); + }, + } + ); + bookmarkMutate( + { bookmark: !favorite }, + { + onError: (error) => { + console.log(error); + if (error instanceof Error) { + toast.error(error.message); + return; + } + + toast.error('북마크를 다시 시도해주세요.'); + }, + } + ); }; const [debouncedToggleFavorite] = useTimeout(handleToggleFavorite, 200); diff --git a/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx b/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx index 1fafcd4c6..90e2b87b6 100644 --- a/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx +++ b/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx @@ -1,10 +1,10 @@ -import { useToastActionContext } from '@fun-eat/design-system'; import type { FormEventHandler } from 'react'; import RecipeNameInput from '../RecipeNameInput/RecipeNameInput'; import RecipeUsedProducts from '../RecipeUsedProducts/RecipeUsedProducts'; import { FormTextarea, ImageUploader } from '@/components/Common'; +import { useToastActionContext } from '@/components/Common/Toast/context'; import { useImageUploader, useFormData } from '@/hooks/common'; import { useRecipeFormValueContext, useRecipeFormActionContext } from '@/hooks/context'; import { useRecipeRegisterFormMutation } from '@/hooks/queries/recipe'; diff --git a/src/components/Review/ReviewFavoriteButton/ReviewFavoriteButton.tsx b/src/components/Review/ReviewFavoriteButton/ReviewFavoriteButton.tsx index 38ed52914..7e572b088 100644 --- a/src/components/Review/ReviewFavoriteButton/ReviewFavoriteButton.tsx +++ b/src/components/Review/ReviewFavoriteButton/ReviewFavoriteButton.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { favoriteButton } from './reviewFavoriteButton.css'; import { SvgIcon, Text } from '@/components/Common'; +import { useToastActionContext } from '@/components/Common/Toast/context'; import { useTimeout } from '@/hooks/common'; import { useReviewFavoriteMutation } from '@/hooks/queries/review'; import { vars } from '@/styles/theme.css'; @@ -23,6 +24,7 @@ const ReviewFavoriteButton = ({ productId, reviewId, favorite, favoriteCount }: const [favoriteInfo, setFavoriteInfo] = useState(initialFavoriteState); const { isFavorite, currentFavoriteCount } = favoriteInfo; + const { toast } = useToastActionContext(); const { mutate } = useReviewFavoriteMutation(productId, reviewId); const handleToggleFavorite = async () => { @@ -34,8 +36,14 @@ const ReviewFavoriteButton = ({ productId, reviewId, favorite, favoriteCount }: mutate( { favorite: !isFavorite }, { - onError: () => { + onError: (error) => { setFavoriteInfo(initialFavoriteState); + if (error instanceof Error) { + toast.error(error.message); + return; + } + + toast.error('좋아요를 다시 시도해주세요.'); }, } ); diff --git a/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx b/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx index 29b8f793b..b37f086cf 100644 --- a/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx +++ b/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx @@ -1,4 +1,4 @@ -import { Spacing, useToastActionContext } from '@fun-eat/design-system'; +import { Spacing } from '@fun-eat/design-system'; import type { FormEventHandler } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -7,6 +7,7 @@ import StarRate from './StarRate/StarRate'; import RebuyCheckbox from '../RebuyCheckbox/RebuyCheckbox'; import { FormTextarea, ImageUploader, SvgIcon, Text } from '@/components/Common'; +import { useToastActionContext } from '@/components/Common/Toast/context'; import type { TagValue } from '@/contexts/ReviewFormContext'; import { useFormData, useImageUploader } from '@/hooks/common'; import { useReviewFormActionContext, useReviewFormValueContext } from '@/hooks/context'; diff --git a/src/contexts/ReviewFormContext.tsx b/src/contexts/ReviewFormContext.tsx index 65e2f18ca..517c151fc 100644 --- a/src/contexts/ReviewFormContext.tsx +++ b/src/contexts/ReviewFormContext.tsx @@ -1,7 +1,7 @@ -import { useToastActionContext } from '@fun-eat/design-system'; import type { PropsWithChildren } from 'react'; import { createContext, useState } from 'react'; +import { useToastActionContext } from '@/components/Common/Toast/context'; import { MAX_DISPLAYED_TAGS_LENGTH, MIN_DISPLAYED_TAGS_LENGTH } from '@/constants'; import type { ReviewRequest } from '@/types/review'; diff --git a/src/hooks/common/useImageUploader.ts b/src/hooks/common/useImageUploader.ts index 1f56a9923..cc1bde453 100644 --- a/src/hooks/common/useImageUploader.ts +++ b/src/hooks/common/useImageUploader.ts @@ -1,7 +1,8 @@ -import { useToastActionContext } from '@fun-eat/design-system'; import imageCompression from 'browser-image-compression'; import { useState } from 'react'; +import { useToastActionContext } from '@/components/Common/Toast/context'; + const isImageFile = (file: File) => file.type !== 'image/png' && file.type !== 'image/jpeg'; const options = { diff --git a/src/hooks/queries/members/useLogoutMutation.ts b/src/hooks/queries/members/useLogoutMutation.ts index 8aed88849..5d603fdac 100644 --- a/src/hooks/queries/members/useLogoutMutation.ts +++ b/src/hooks/queries/members/useLogoutMutation.ts @@ -1,8 +1,8 @@ -import { useToastActionContext } from '@fun-eat/design-system'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { logoutApi } from '@/apis'; +import { useToastActionContext } from '@/components/Common/Toast/context'; import { PATH } from '@/constants/path'; const useLogoutMutation = () => { diff --git a/src/hooks/queries/recipe/useRecipeFavoriteMutation.ts b/src/hooks/queries/recipe/useRecipeFavoriteMutation.ts index 38da1ec54..af59bc7f4 100644 --- a/src/hooks/queries/recipe/useRecipeFavoriteMutation.ts +++ b/src/hooks/queries/recipe/useRecipeFavoriteMutation.ts @@ -37,13 +37,6 @@ const useRecipeFavoriteMutation = (recipeId: number) => { }, onError: (error, _, context) => { queryClient.setQueryData(queryKey, context?.previousRequest); - - if (error instanceof Error) { - toast.error(error.message); - return; - } - - toast.error('좋아요를 다시 시도해주세요.'); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKey }); diff --git a/src/hooks/queries/review/useReviewFavoriteMutation.ts b/src/hooks/queries/review/useReviewFavoriteMutation.ts index c80a8c262..d9efac9c3 100644 --- a/src/hooks/queries/review/useReviewFavoriteMutation.ts +++ b/src/hooks/queries/review/useReviewFavoriteMutation.ts @@ -1,4 +1,3 @@ -import { useToastActionContext } from '@fun-eat/design-system'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { productApi } from '@/apis'; @@ -12,20 +11,11 @@ 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), - onError: (error) => { - if (error instanceof Error) { - toast.error(error.message); - return; - } - - toast.error('좋아요를 다시 시도해주세요.'); - }, onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKey }), }); }; diff --git a/src/hooks/search/useSearch.ts b/src/hooks/search/useSearch.ts index 5b062295d..eaa8be092 100644 --- a/src/hooks/search/useSearch.ts +++ b/src/hooks/search/useSearch.ts @@ -1,10 +1,10 @@ -import { useToastActionContext } from '@fun-eat/design-system'; import type { ChangeEventHandler, FormEventHandler, MouseEventHandler } from 'react'; import { useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useGA } from '../common'; +import { useToastActionContext } from '@/components/Common/Toast/context'; import { PATH } from '@/constants/path'; import { getLocalStorage, setLocalStorage } from '@/utils/localStorage'; diff --git a/src/index.tsx b/src/index.tsx index b2428c134..7618dc146 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ import ReactGA from 'react-ga4'; import { RouterProvider } from 'react-router-dom'; import { SvgSprite } from './components/Common'; +import { ToastProvider } from './components/Common/Toast/context/ToastContext'; import { ENVIRONMENT } from './constants'; import router from './router'; @@ -41,8 +42,10 @@ root.render( - - + + + + diff --git a/src/pages/MemberModifyPage/MemberModifyPage.tsx b/src/pages/MemberModifyPage/MemberModifyPage.tsx index ca2f024ed..666d39890 100644 --- a/src/pages/MemberModifyPage/MemberModifyPage.tsx +++ b/src/pages/MemberModifyPage/MemberModifyPage.tsx @@ -1,4 +1,3 @@ -import { useToastActionContext } from '@fun-eat/design-system'; import type { ChangeEventHandler, FormEventHandler } from 'react'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -17,6 +16,7 @@ import { } from './memberModifyPage.css'; import { SvgIcon, TopBar } from '@/components/Common'; +import { useToastActionContext } from '@/components/Common/Toast/context'; import { MemberModifyInput } from '@/components/Members'; import { IMAGE_MAX_SIZE } from '@/constants'; import { useFormData, useImageUploader } from '@/hooks/common';