diff --git a/README.md b/README.md index e69de29b..36effb02 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,10 @@ +## 2단계 - Error, Loading Status 핸들링 하기 + +### 기능 구현 목록 +- 각 API에서 Loading 상태에 대한 UI 대응 +- [x] Loading 상태를 보여주는 UI component 만들기 +- [x] 1단계에서 사용 중인 API에 적용하기 + +- [x] 데이터가 없는 경우에 대한 UI component 만들기 +- [x] Http Status에 따라 Error UI component 만들기 +- [x] 1단계에서 사용 중인 API에 적용하기 \ No newline at end of file diff --git a/src/components/common/API/api.tsx b/src/components/common/API/api.tsx new file mode 100644 index 00000000..fe39bbaf --- /dev/null +++ b/src/components/common/API/api.tsx @@ -0,0 +1,20 @@ +import axios from 'axios'; + +const Api = axios.create({ + baseURL: 'https://react-gift-mock-api-userjmmm.vercel.app/', +}); + +export const fetchData = async (endpoint: string, params = {}) => { + try { + const response = await Api.get(endpoint, { params }); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { // axios는 자동으로 JSON 변환해줌 + const code = error.response?.status?.toString() || 'UNKNOWN_ERROR'; + const description = error.response?.data?.description || '알 수 없는 오류가 발생했어요.'; + throw Object.assign(new Error(), {code, description }); + } + } +}; + +export default Api; \ No newline at end of file diff --git a/src/components/common/Status/emptyData.tsx b/src/components/common/Status/emptyData.tsx new file mode 100644 index 00000000..742a16dc --- /dev/null +++ b/src/components/common/Status/emptyData.tsx @@ -0,0 +1,27 @@ +import styled from "@emotion/styled"; + +interface EmptyDataProps { + message?: string; +} + +const EmptyData = ({ message = "보여줄 데이터가 없어요 🤨" }: EmptyDataProps) => { + return ( + + {message} + + ) +}; + +const EmptyDataContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + color: #555; + font-size: 18px; + text-align: center; + padding: 20px; +`; + +export default EmptyData; \ No newline at end of file diff --git a/src/components/common/Status/errorMessage.tsx b/src/components/common/Status/errorMessage.tsx new file mode 100644 index 00000000..68057b2c --- /dev/null +++ b/src/components/common/Status/errorMessage.tsx @@ -0,0 +1,37 @@ +import styled from "@emotion/styled"; + +interface ErrorMessageProps { + code?: string; + message: string; +} + +const ErrorMessage = ({ code, message }: ErrorMessageProps) => { + return ( + + {code && (Error Code: {code}) } {message} 😔 + + ) +}; + +const ErrorContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + font-size: 18px; + text-align: center; + padding: 20px; +`; + +const Code = styled.span` + margin-right: 10px; + font-size: 16px; + color: #d32f2f; +`; + +const Emoji = styled.span` + margin-left: 5px; +`; + +export default ErrorMessage; \ No newline at end of file diff --git a/src/components/common/Status/loading.tsx b/src/components/common/Status/loading.tsx new file mode 100644 index 00000000..a2229c83 --- /dev/null +++ b/src/components/common/Status/loading.tsx @@ -0,0 +1,35 @@ +import { keyframes } from '@emotion/react'; +import styled from '@emotion/styled'; + +const Loading: React.FC = () => { + return ( + + + + ); +}; + +const spin = keyframes` + to { + transform: rotate(360deg); + } +`; + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +`; + +const Spinner = styled.div` + border: 4px solid rgba(0, 0, 0, 0.1); + border-left-color: #22a6b3; + border-radius: 50%; + width: 40px; + height: 40px; + animation: ${spin} 1s linear infinite; +`; + +export default Loading; \ No newline at end of file diff --git a/src/components/features/Home/GoodsRankingSection/index.tsx b/src/components/features/Home/GoodsRankingSection/index.tsx index 9464d67c..85b6b410 100644 --- a/src/components/features/Home/GoodsRankingSection/index.tsx +++ b/src/components/features/Home/GoodsRankingSection/index.tsx @@ -1,28 +1,93 @@ import styled from '@emotion/styled'; -import { useState } from 'react'; +import type { AxiosError } from 'axios'; +import React, { useEffect, useState } from 'react'; import { Container } from '@/components/common/layouts/Container'; +import EmptyData from '@/components/common/Status/emptyData'; +import ErrorMessage from '@/components/common/Status/errorMessage'; +import Loading from '@/components/common/Status/loading'; import { breakpoints } from '@/styles/variants'; import type { RankingFilterOption } from '@/types'; -import { GoodsMockList } from '@/types/mock'; +import { fetchData } from '../../../common/API/api'; import { GoodsRankingFilter } from './Filter'; import { GoodsRankingList } from './List'; -export const GoodsRankingSection = () => { +interface ProductData { + id: number; + name: string; + imageURL: string; + wish: { + wishCount: number; + isWished: boolean; + }; + price: { + basicPrice: number; + discountRate: number; + sellingPrice: number; + }; + brandInfo: { + id: number; + name: string; + imageURL: string; + }; +} + +interface FetchState { + isLoading: boolean; + isError: boolean; + errorCode?: string; + errorMessage?: string; + data: T | null; +} + +export const GoodsRankingSection: React.FC = () => { const [filterOption, setFilterOption] = useState({ targetType: 'ALL', rankType: 'MANY_WISH', }); - // GoodsMockData를 21번 반복 생성 + const [fetchState, setFetchState] = useState>({ + isLoading: true, + isError: false, + data: null, + }); + + useEffect(() => { + const fetchRankingProducts = async (filters: RankingFilterOption) => { + setFetchState({ isLoading: true, isError: false, data: null }); + try { + const response = await fetchData('/api/v1/ranking/products', filters); + setFetchState({ isLoading: false, isError: false, data: response.products }); + } catch (error) { + const axiosError = error as AxiosError; + setFetchState({ + isLoading: false, + isError: true, + data: null, + errorMessage: axiosError.message, + errorCode: axiosError.code, + }); + } + }; + + fetchRankingProducts(filterOption); + }, [filterOption]); return ( 실시간 급상승 선물랭킹 - + {fetchState.isLoading ? ( + + ) : fetchState.isError ? ( + + ) : fetchState.data && fetchState.data.length > 0 ? ( + + ) : ( + + )} ); @@ -50,3 +115,5 @@ const Title = styled.h2` line-height: 50px; } `; + +export default GoodsRankingSection; \ No newline at end of file diff --git a/src/components/features/Home/ThemeCategorySection/index.tsx b/src/components/features/Home/ThemeCategorySection/index.tsx index d82e3afe..f8a2c926 100644 --- a/src/components/features/Home/ThemeCategorySection/index.tsx +++ b/src/components/features/Home/ThemeCategorySection/index.tsx @@ -1,14 +1,76 @@ import styled from '@emotion/styled'; +import type { AxiosError } from 'axios'; +import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { Container } from '@/components/common/layouts/Container'; import { Grid } from '@/components/common/layouts/Grid'; +import ErrorMessage from '@/components/common/Status/errorMessage'; +import Loading from '@/components/common/Status/loading'; import { getDynamicPath } from '@/routes/path'; import { breakpoints } from '@/styles/variants'; +import { fetchData } from '../../../common/API/api'; import { ThemeCategoryItem } from './ThemeCategoryItem'; -export const ThemeCategorySection = () => { + +interface ThemeData { + id: number; + key: string; + label: string; + title: string; + description: string; + backgroundColor: string; + imageURL?: string; +} + +interface FetchState { + isLoading: boolean; + isError: boolean; + errorCode?: string; + errorMessage?: string; + data: T | null; +} + +export const ThemeCategorySection: React.FC = () => { + const [fetchState, setFetchState] = useState>({ + isLoading: true, + isError: false, + data: null, + }); + + useEffect(() => { + const fetchThemes = async () => { + setFetchState({ isLoading: true, isError: false, data: null }); + try { + const data = await fetchData('/api/v1/themes'); + setFetchState({ isLoading: false, isError: false, data: data.themes }); + } catch (error) { + const axiosError = error as AxiosError; + setFetchState({ + isLoading: false, + isError: true, + data: null, + errorCode: axiosError.code, + errorMessage: axiosError.message, + }); + } + }; + + fetchThemes(); + }, []); + + if (fetchState.isLoading) + return ; + if (fetchState.isError) + return ( + + ); + + return ( @@ -18,78 +80,14 @@ export const ThemeCategorySection = () => { md: 6, }} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {fetchState.data?.map((theme) => ( + + + + ))} @@ -103,3 +101,5 @@ const Wrapper = styled.section` padding: 45px 52px 23px; } `; + +export default ThemeCategorySection; diff --git a/src/components/features/Theme/ThemeGoodsSection/index.tsx b/src/components/features/Theme/ThemeGoodsSection/index.tsx index 8edbf70e..d8aca79e 100644 --- a/src/components/features/Theme/ThemeGoodsSection/index.tsx +++ b/src/components/features/Theme/ThemeGoodsSection/index.tsx @@ -1,16 +1,85 @@ import styled from '@emotion/styled'; +import type { AxiosError } from 'axios'; +import { useCallback,useEffect, useState } from 'react'; +import { fetchData } from '@/components/common/API/api'; import { DefaultGoodsItems } from '@/components/common/GoodsItem/Default'; import { Container } from '@/components/common/layouts/Container'; import { Grid } from '@/components/common/layouts/Grid'; +import EmptyData from '@/components/common/Status/emptyData'; +import ErrorMessage from '@/components/common/Status/errorMessage'; +import Loading from '@/components/common/Status/loading'; import { breakpoints } from '@/styles/variants'; -import { GoodsMockList } from '@/types/mock'; + +interface ProductData { + id: number; + name: string; + imageURL: string; + price: { + sellingPrice: number; + }; + brandInfo: { + name: string; + }; +} + +interface FetchState { + isLoading: boolean; + isError: boolean; + errorCode?: string; + errorMessage?: string; + data: T; +} type Props = { themeKey: string; }; -export const ThemeGoodsSection = ({}: Props) => { +const ThemeGoodsSection: React.FC = ({ themeKey }) => { + const [fetchState, setFetchState] = useState>({ + isLoading: true, + isError: false, + data: [], + + }); + + const fetchProducts = useCallback(async (key: string) => { + try { + const data = await fetchData(`/api/v1/themes/${key}/products?maxResults=20`); + setFetchState({ + isLoading: false, + isError: false, + data: data.products, + }); + } catch (error) { + console.error('Error fetching products:', error); + const axiosError = error as AxiosError; + setFetchState({ + isLoading: false, + isError: true, + data: [], + errorMessage: axiosError.message, + errorCode: axiosError.code, + }); + + } + }, []); + +//초기 로딩 비동기 통신 + useEffect(() => { + if (themeKey) { + setFetchState({ isLoading: true, isError: false, data: []}); + fetchProducts(themeKey); + } + }, [themeKey, fetchProducts]); + + if (fetchState.isLoading) + return ; + if (fetchState.isError) + return ; + if (!fetchState.data || fetchState.data.length === 0) + return ; + return ( @@ -21,7 +90,7 @@ export const ThemeGoodsSection = ({}: Props) => { }} gap={16} > - {GoodsMockList.map(({ id, imageURL, name, price, brandInfo }) => ( + {fetchState.data.map(({ id, imageURL, name, price, brandInfo }) => ( { + isLoading: boolean; + isError: boolean; + errorMessage?: string; + errorCode?: string; + data: T | null; +} type Props = { themeKey: string; }; -export const ThemeHeroSection = ({ themeKey }: Props) => { - const currentTheme = getCurrentTheme(themeKey, ThemeMockList); +const ThemeHeroSection: React.FC = ({ themeKey }) => { + const [fetchState, setFetchState] = useState>({ + isLoading: true, + isError: false, + data: null, + }); + + const navigate = useNavigate(); + + useEffect(() => { + const fetchTheme = async (key: string) => { + setFetchState({ isLoading: true, isError: false, data: null }); + try { + const data = await fetchData('/api/v1/themes', { key }); + setFetchState({ isLoading: false, isError: false, data: data.themes }); + } catch (error) { + const axiosError = error as AxiosError; + setFetchState({ + isLoading: false, + isError: true, + data: null, + errorMessage: axiosError.message, + errorCode: axiosError.code, + }); + } + }; + if (themeKey) { + fetchTheme(themeKey); + } + }, [themeKey]); + + useEffect(() => { + if (!fetchState.isLoading && !fetchState.isError && !fetchState.data?.find((theme) => theme.key === themeKey)) { + console.log('Invalid theme key, redirecting...'); + navigate('/'); + } + }, [fetchState, themeKey, navigate]); + + if (fetchState.isLoading) + return ; + if (fetchState.isError) + return ; + + const currentTheme = fetchState.data?.find((theme) => theme.key === themeKey); if (!currentTheme) { return null; } @@ -83,6 +146,4 @@ const Description = styled.p` } `; -export const getCurrentTheme = (themeKey: string, themeList: ThemeData[]) => { - return themeList.find((theme) => theme.key === themeKey); -}; +export default ThemeHeroSection; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index ab5f7ad6..c322b323 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,13 +1,10 @@ import '@/styles'; -import React from 'react'; import ReactDOM from 'react-dom/client'; import App from '@/App'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( - - , ); diff --git a/src/pages/Theme/index.tsx b/src/pages/Theme/index.tsx index 4d02e6c1..744e4658 100644 --- a/src/pages/Theme/index.tsx +++ b/src/pages/Theme/index.tsx @@ -1,17 +1,10 @@ -import { Navigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; -import { ThemeGoodsSection } from '@/components/features/Theme/ThemeGoodsSection'; -import { getCurrentTheme, ThemeHeroSection } from '@/components/features/Theme/ThemeHeroSection'; -import { RouterPath } from '@/routes/path'; -import { ThemeMockList } from '@/types/mock'; +import ThemeGoodsSection from '@/components/features/Theme/ThemeGoodsSection'; +import ThemeHeroSection from '@/components/features/Theme/ThemeHeroSection'; export const ThemePage = () => { const { themeKey = '' } = useParams<{ themeKey: string }>(); - const currentTheme = getCurrentTheme(themeKey, ThemeMockList); - - if (!currentTheme) { - return ; - } return ( <> @@ -20,3 +13,5 @@ export const ThemePage = () => { ); }; + +export default ThemePage; \ No newline at end of file