diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index b4cbc42b8..dde3f186f 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -8,6 +8,7 @@ import { initialize, mswLoader } from 'msw-storybook-addon'; import handlers from '@mocks/handlers'; import { ModalProvider } from '@contexts/ModalContext'; import { withRouter } from 'storybook-addon-remix-react-router'; +import ToastProvider from '@contexts/ToastContext'; initialize(); @@ -31,7 +32,9 @@ const preview: Preview = { - + + + diff --git a/frontend/src/api/APIClient.ts b/frontend/src/api/APIClient.ts index 62048ee63..9354db385 100644 --- a/frontend/src/api/APIClient.ts +++ b/frontend/src/api/APIClient.ts @@ -95,13 +95,8 @@ export default class APIClient implements APIClientType { const response = await fetch(url, this.getRequestInit({ method, body, hasCookies })); if (!response.ok) { - const { status, statusText } = response; - const defaultErrorMessage = `API통신에 실패했습니다: ${statusText}`; - - const errorData = await response.json().catch(() => null); - const errorMessage = `${defaultErrorMessage}${errorData?.message ? ` - ${errorData.message}` : ''}`; - - throw new ApiError({ message: errorMessage, statusCode: status, method }); + const json = await response.json(); + throw new ApiError({ message: json.detail ?? '', statusCode: response.status, method }); } // Content-Type 확인 후 응답 처리 diff --git a/frontend/src/components/common/Toast/Toast.stories.tsx b/frontend/src/components/common/Toast/Toast.stories.tsx new file mode 100644 index 000000000..f41a85172 --- /dev/null +++ b/frontend/src/components/common/Toast/Toast.stories.tsx @@ -0,0 +1,84 @@ +/* eslint-disable max-len */ +import type { Meta, StoryObj } from '@storybook/react'; +import Toast from '.'; + +const meta: Meta = { + title: 'Common/Toast', + component: Toast, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Toast 컴포넌트는 메시지를 화면에 나타내며, type에 따라 스타일이 달라집니다.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + message: { + description: '표시할 메시지입니다.', + control: { type: 'text' }, + defaultValue: 'This is a toast message', + }, + type: { + description: 'Toast의 타입입니다.', + control: { type: 'select' }, + options: ['default', 'success', 'error', 'primary'], + defaultValue: 'default', + }, + visible: { + description: 'Toast의 렌더링 여부입니다.', + control: { type: 'boolean' }, + defaultValue: true, + }, + }, + args: { + visible: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + message: 'Default Alert!', + type: 'default', + }, +}; + +export const Success: Story = { + args: { + message: 'Success Alert!', + type: 'success', + }, +}; + +export const Error: Story = { + args: { + message: 'Error Alert!', + type: 'error', + }, +}; + +export const Primary: Story = { + args: { + message: 'Primary Alert!', + type: 'primary', + }, +}; + +export const LongAlert: Story = { + args: { + message: 'Primary Alert Primary Alert Primary Alert Primary Alert! ', + type: 'primary', + }, +}; + +export const SuperLongAlert: Story = { + args: { + message: + 'Primary Alert Primary Alert Primary Alert Primary Alert! Primary Alert Primary Alert Primary Alert Primary Alert Primary Alert Primary Alert Primary Alert Primary !!! Alert Primary Alert Primary Alert Primary Alert Primary Alert Primary', + type: 'primary', + }, +}; diff --git a/frontend/src/components/common/Toast/index.tsx b/frontend/src/components/common/Toast/index.tsx new file mode 100644 index 000000000..10b23a9be --- /dev/null +++ b/frontend/src/components/common/Toast/index.tsx @@ -0,0 +1,40 @@ +import { ToastType } from '@contexts/ToastContext'; +import { useEffect, useState } from 'react'; +import S from './style'; + +interface ToastProps { + message: string; + type?: ToastType; + visible: boolean; +} + +export default function Toast({ message, type = 'default', visible }: ToastProps) { + return ( + + {message} + + ); +} + +export function ToastModal({ message, type = 'default' }: Omit) { + const [visible, setVisible] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => { + setVisible(false); + }, 3000); + + return () => clearTimeout(timer); + }, []); + + return ( + + ); +} diff --git a/frontend/src/components/common/Toast/style.ts b/frontend/src/components/common/Toast/style.ts new file mode 100644 index 000000000..4eeadf8b5 --- /dev/null +++ b/frontend/src/components/common/Toast/style.ts @@ -0,0 +1,86 @@ +/* eslint-disable default-case */ +/* eslint-disable consistent-return */ +import { keyframes } from '@emotion/react'; +import styled from '@emotion/styled'; + +interface ToastContainerProps { + type: 'default' | 'success' | 'error' | 'primary'; +} + +const slideIn = keyframes` + from { + transform: translateY(-20px) translateX(-50%); + opacity: 0; + } + to { + transform: translateY(0) translateX(-50%); + opacity: 1; + } +`; + +const slideOut = keyframes` + from { + transform: translateY(0) translateX(-50%); + opacity: 1; + } + to { + transform: translateY(-20px) translateX(-50%); + opacity: 0; + } +`; + +const ToastContainer = styled.div` + position: absolute; + top: 5%; + left: 50%; + transform: translate(-50%, -50%); + + min-width: 20rem; + max-width: max(50vw, 32rem); + display: flex; + align-items: center; + justify-content: center; + + width: fit-content; + height: 4.8rem; + + padding: 0 1.2rem; + + border-radius: 0.8rem; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + + background-color: ${({ type, theme }) => { + switch (type) { + case 'default': + return theme.baseColors.grayscale[50]; + case 'success': + return theme.colors.feedback.success; + case 'error': + return theme.colors.feedback.error; + case 'primary': + return theme.colors.brand.primary; + } + }}; + color: ${({ type, theme }) => + type === 'default' ? theme.baseColors.grayscale[800] : theme.baseColors.grayscale[50]}; + ${({ theme }) => theme.typography.common.block} + + animation: ${({ visible }) => (visible ? slideIn : slideOut)} 0.5s ease-out; + animation-fill-mode: forwards; + z-index: 1000; +`; + +const Message = styled.div` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + max-width: 100%; +`; + +const S = { + ToastContainer, + Message, +}; + +export default S; diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx new file mode 100644 index 000000000..679dde896 --- /dev/null +++ b/frontend/src/contexts/ToastContext.tsx @@ -0,0 +1,66 @@ +import { ToastModal } from '@components/common/Toast'; +import { createContext, useState, ReactNode, useContext, useMemo, useCallback, useRef } from 'react'; + +type ToastContextType = { + alert: (message: string) => void; + error: (message: string) => void; + success: (message: string) => void; + primary: (message: string) => void; +}; + +export type ToastType = 'default' | 'success' | 'error' | 'primary'; + +const ToastContext = createContext(null); + +export default function ToastProvider({ children }: { children: ReactNode }) { + const [toastList, setToastList] = useState<{ id: number; type: ToastType; message: string }[]>([]); + const idRef = useRef(0); + + const removeToast = (id: number) => { + setToastList((prev) => prev.filter((toast) => toast.id !== id)); + }; + + const handleAlert = useCallback( + (type: ToastType) => (message: string) => { + const id = idRef.current; + setToastList((prev) => [...prev, { id, type, message }]); + idRef.current += 1; + + setTimeout(() => removeToast(id), 4000); + }, + [], + ); + + const providerValue = useMemo( + () => ({ + alert: handleAlert('default'), + error: handleAlert('error'), + success: handleAlert('success'), + primary: handleAlert('primary'), + }), + [handleAlert], + ); + + return ( + + {toastList.map(({ id, type, message }) => ( + + ))} + {children} + + ); +} + +export const useToast = () => { + const value = useContext(ToastContext); + + if (!value) { + throw new Error('Toast Context가 존재하지 않습니다.'); + } + + return value; +}; diff --git a/frontend/src/hooks/useSignIn/index.tsx b/frontend/src/hooks/useSignIn/index.tsx index 11d9358a9..2fb567ff8 100644 --- a/frontend/src/hooks/useSignIn/index.tsx +++ b/frontend/src/hooks/useSignIn/index.tsx @@ -4,14 +4,12 @@ import { useNavigate } from 'react-router-dom'; export default function useSignIn() { const navigate = useNavigate(); + const signInMutate = useMutation({ mutationFn: ({ email, password }: { email: string; password: string }) => authApi.login({ email, password }), onSuccess: ({ clubId }) => { navigate(`/dashboard/${clubId}/posts`); }, - onError: (error) => { - window.alert(error.message); - }, }); return { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0b6046374..f57451431 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,12 +1,12 @@ import * as Sentry from '@sentry/react'; import ReactGA from 'react-ga4'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { ModalProvider } from '@contexts/ModalContext'; import { Global, ThemeProvider } from '@emotion/react'; +import ToastProvider from '@contexts/ToastContext'; import { BASE_URL } from '@constants/constants'; import globalStyles from './styles/globalStyles'; @@ -36,25 +36,16 @@ async function setPrev() { }); } -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - throwOnError: true, - retry: 0, - }, - }, -}); - setPrev().then(() => { ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + - - + + , ); diff --git a/frontend/src/mocks/handlers/authHandlers.ts b/frontend/src/mocks/handlers/authHandlers.ts index 25ebb197d..825803d7f 100644 --- a/frontend/src/mocks/handlers/authHandlers.ts +++ b/frontend/src/mocks/handlers/authHandlers.ts @@ -15,9 +15,8 @@ const authHandlers = [ await new Promise((resolve) => setTimeout(resolve, 2000)); if (!body.email || !body.password || body.email !== 'admin@gmail.com' || body.password !== 'admin') { - return new Response(null, { + return new Response(JSON.stringify({ detail: '로그인 정보가 일치하지 않습니다.' }), { status: 401, - statusText: '[Mock Data Error] Login Failed', }); } @@ -27,7 +26,6 @@ const authHandlers = [ return new Response(responseBody, { status: 201, - statusText: 'Created', headers: { 'Content-Type': 'application/json', }, diff --git a/frontend/src/mocks/handlers/memberHandlers.ts b/frontend/src/mocks/handlers/memberHandlers.ts index e6d046b18..be78aba89 100644 --- a/frontend/src/mocks/handlers/memberHandlers.ts +++ b/frontend/src/mocks/handlers/memberHandlers.ts @@ -17,7 +17,7 @@ const membersHandlers = [ await new Promise((resolve) => setTimeout(resolve, 2000)); if (!body.email || !body.password || !body.clubName || !body.phone) { - return new Response(null, { + return new Response(JSON.stringify({ detail: '회원가입 정보를 확인하세요.' }), { status: 401, statusText: '[Mock Data Error] Sign Up Failed', }); diff --git a/frontend/src/pages/SignIn/index.tsx b/frontend/src/pages/SignIn/index.tsx index 1659acbe5..a27ceb7ac 100644 --- a/frontend/src/pages/SignIn/index.tsx +++ b/frontend/src/pages/SignIn/index.tsx @@ -10,14 +10,14 @@ export default function SignIn() { const { formData, register } = useForm({ initialValues: { email: '', password: '' } }); const { signInMutate } = useSignIn(); - const handleSignUp: React.FormEventHandler = (event) => { + const handleSignIn: React.FormEventHandler = (event) => { event.preventDefault(); signInMutate.mutate(formData); }; return ( - + 로그인 diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index 8e3412bbd..78d521a6c 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @tanstack/query/stable-query-client */ import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import ErrorPage from '@pages/ErrorPage'; @@ -9,7 +10,8 @@ import ConfirmApply from '@pages/ConfirmApply'; import DashboardLayout from '@pages/DashboardLayout'; import DashboardList from '@pages/DashBoardList'; import DashboardCreate from '@pages/DashboardCreate'; - +import { useToast } from '@contexts/ToastContext'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import App from '../App'; const router = createBrowserRouter( @@ -62,5 +64,23 @@ const router = createBrowserRouter( ); export default function AppRouter() { - return ; + const { error: alertError } = useToast(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + throwOnError: true, + retry: 0, + }, + mutations: { + onError: (error) => { + alertError(error.message); + }, + }, + }, + }); + return ( + + + + ); }