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';