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