From f6f8cf381a288403d766f80df301466c81e02a46 Mon Sep 17 00:00:00 2001 From: Jungyoon Moon <102630375+nnoonjy@users.noreply.github.com> Date: Tue, 9 Jul 2024 00:01:26 +0900 Subject: [PATCH 01/17] docs: update README.md --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index e69de29b..d70ec659 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,32 @@ +# 카카오 테크 캠퍼스 - 프론트엔드 카카오 선물하기 편 + +[🔗 link](https://edu.nextstep.camp/s/hazAC9xa/ls/WAz8qraH) + +---- +## Week 3 + +### Step 1 +- [] 첨부된 oas.yaml 파일을 토대로 Request, Response Type 정의 +- [] React Query를 사용하지 말고 axios를 사용해서 구현 +- [] 첨부된 oas.yaml 파일과 목 API URL을 사용하여 API 구현 + - 메인 페이지 - Theme 카테고리 섹션 + [] /api/v1/themes API를 사용하여 Section 구현 + [] API는 Axios또는 React Query 등을 모두 활용해서 구현해도 ok + - 메인 페이지 - 실시간 급상승 선물랭킹 섹션 + [] /api/v1/ranking/products API를 사용하여 Section 구현 (Axios 사용 가능) + [] 필터 조건을 선택 하면 해당 조건에 맞게 API를 요청하여 보여지게 하기 + - Theme 페이지 - header + [] url의 pathParams와 /api/v1/themes API를 사용하여 Section 구현 + [] themeKey가 잘못 된 경우 메인 페이지로 연결 + - Theme 페이지 - 상품 목록 섹션 + [] /api/v1/themes/{themeKey}/products API를 사용하여 상품 목록 구현 + [] API 요청 시 한번에 20개의 상품 목록이 내려오도록 하기 + +### Step 2 +- [] 각 API에서 Loading 상태에 대한 UI 대응 하기 +- [] 데이터가 없는 경우에 대한 UI 대응 하기 +- [] Http Status에 따라 Error를 다르게 처리하기 + +### Step 3 +스크롤을 내리면 추가로 데이터를 요청하여 보여지게 하기 +1단계에서 구현한 API를 react-query를 사용해서 구현하기 From f531ab9a01928bd166318b40498c596db5ab95f9 Mon Sep 17 00:00:00 2001 From: Jungyoon Moon <102630375+nnoonjy@users.noreply.github.com> Date: Tue, 9 Jul 2024 00:02:17 +0900 Subject: [PATCH 02/17] docs: update README.md --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d70ec659..fa2c9cbf 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,21 @@ ## Week 3 ### Step 1 -- [] 첨부된 oas.yaml 파일을 토대로 Request, Response Type 정의 -- [] React Query를 사용하지 말고 axios를 사용해서 구현 -- [] 첨부된 oas.yaml 파일과 목 API URL을 사용하여 API 구현 +- [] 첨부된 oas.yaml 파일을 토대로 Request, Response Type 정의 +- [] React Query를 사용하지 말고 axios를 사용해서 구현 +- [] 첨부된 oas.yaml 파일과 목 API URL을 사용하여 API 구현 - 메인 페이지 - Theme 카테고리 섹션 - [] /api/v1/themes API를 사용하여 Section 구현 - [] API는 Axios또는 React Query 등을 모두 활용해서 구현해도 ok + - [] /api/v1/themes API를 사용하여 Section 구현 + - [] API는 Axios또는 React Query 등을 모두 활용해서 구현해도 ok - 메인 페이지 - 실시간 급상승 선물랭킹 섹션 - [] /api/v1/ranking/products API를 사용하여 Section 구현 (Axios 사용 가능) - [] 필터 조건을 선택 하면 해당 조건에 맞게 API를 요청하여 보여지게 하기 + - [] /api/v1/ranking/products API를 사용하여 Section 구현 (Axios 사용 가능) + - [] 필터 조건을 선택 하면 해당 조건에 맞게 API를 요청하여 보여지게 하기 - Theme 페이지 - header - [] url의 pathParams와 /api/v1/themes API를 사용하여 Section 구현 - [] themeKey가 잘못 된 경우 메인 페이지로 연결 + - [] url의 pathParams와 /api/v1/themes API를 사용하여 Section 구현 + - [] themeKey가 잘못 된 경우 메인 페이지로 연결 - Theme 페이지 - 상품 목록 섹션 - [] /api/v1/themes/{themeKey}/products API를 사용하여 상품 목록 구현 - [] API 요청 시 한번에 20개의 상품 목록이 내려오도록 하기 + - [] /api/v1/themes/{themeKey}/products API를 사용하여 상품 목록 구현 + - [] API 요청 시 한번에 20개의 상품 목록이 내려오도록 하기 ### Step 2 - [] 각 API에서 Loading 상태에 대한 UI 대응 하기 From f0842d4bca44d1f77458c2d573e40c3f55a18bd5 Mon Sep 17 00:00:00 2001 From: Jungyoon Moon <102630375+nnoonjy@users.noreply.github.com> Date: Tue, 9 Jul 2024 00:02:54 +0900 Subject: [PATCH 03/17] docs: update README.md --- README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index fa2c9cbf..15d5b027 100644 --- a/README.md +++ b/README.md @@ -6,26 +6,26 @@ ## Week 3 ### Step 1 -- [] 첨부된 oas.yaml 파일을 토대로 Request, Response Type 정의 -- [] React Query를 사용하지 말고 axios를 사용해서 구현 -- [] 첨부된 oas.yaml 파일과 목 API URL을 사용하여 API 구현 +- [ ] 첨부된 oas.yaml 파일을 토대로 Request, Response Type 정의 +- [ ] React Query를 사용하지 말고 axios를 사용해서 구현 +- [ ] 첨부된 oas.yaml 파일과 목 API URL을 사용하여 API 구현 - 메인 페이지 - Theme 카테고리 섹션 - - [] /api/v1/themes API를 사용하여 Section 구현 - - [] API는 Axios또는 React Query 등을 모두 활용해서 구현해도 ok + - [ ] /api/v1/themes API를 사용하여 Section 구현 + - [ ] API는 Axios또는 React Query 등을 모두 활용해서 구현해도 ok - 메인 페이지 - 실시간 급상승 선물랭킹 섹션 - - [] /api/v1/ranking/products API를 사용하여 Section 구현 (Axios 사용 가능) - - [] 필터 조건을 선택 하면 해당 조건에 맞게 API를 요청하여 보여지게 하기 + - [ ] /api/v1/ranking/products API를 사용하여 Section 구현 (Axios 사용 가능) + - [ ] 필터 조건을 선택 하면 해당 조건에 맞게 API를 요청하여 보여지게 하기 - Theme 페이지 - header - - [] url의 pathParams와 /api/v1/themes API를 사용하여 Section 구현 - - [] themeKey가 잘못 된 경우 메인 페이지로 연결 + - [ ] url의 pathParams와 /api/v1/themes API를 사용하여 Section 구현 + - [ ] themeKey가 잘못 된 경우 메인 페이지로 연결 - Theme 페이지 - 상품 목록 섹션 - - [] /api/v1/themes/{themeKey}/products API를 사용하여 상품 목록 구현 - - [] API 요청 시 한번에 20개의 상품 목록이 내려오도록 하기 + - [ ] /api/v1/themes/{themeKey}/products API를 사용하여 상품 목록 구현 + - [ ] API 요청 시 한번에 20개의 상품 목록이 내려오도록 하기 ### Step 2 -- [] 각 API에서 Loading 상태에 대한 UI 대응 하기 -- [] 데이터가 없는 경우에 대한 UI 대응 하기 -- [] Http Status에 따라 Error를 다르게 처리하기 +- [ ] 각 API에서 Loading 상태에 대한 UI 대응 하기 +- [ ] 데이터가 없는 경우에 대한 UI 대응 하기 +- [ ] Http Status에 따라 Error를 다르게 처리하기 ### Step 3 스크롤을 내리면 추가로 데이터를 요청하여 보여지게 하기 From 9f50d7851caee28000bdc441d7e651e56e273a5d Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Tue, 9 Jul 2024 01:41:49 +0900 Subject: [PATCH 04/17] feat: call Themes Api --- package-lock.json | 29 +++--- package.json | 1 + .../Home/ThemeCategorySection/index.tsx | 93 +++++-------------- src/libs/api.tsx | 22 +++++ src/types/index.ts | 8 ++ 5 files changed, 68 insertions(+), 85 deletions(-) create mode 100644 src/libs/api.tsx diff --git a/package-lock.json b/package-lock.json index 89581c64..74216d7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", + "axios": "^1.7.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.1" @@ -11988,8 +11989,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -12061,6 +12061,16 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -13498,7 +13508,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -15481,7 +15490,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -18405,10 +18413,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", - "dev": true, + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -18614,7 +18621,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -24744,7 +24750,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -24753,7 +24758,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -27576,8 +27580,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/psl": { "version": "1.9.0", diff --git a/package.json b/package.json index 0a6f0b8f..f1f65cfa 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", + "axios": "^1.7.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.1" diff --git a/src/components/features/Home/ThemeCategorySection/index.tsx b/src/components/features/Home/ThemeCategorySection/index.tsx index d82e3afe..04bfc840 100644 --- a/src/components/features/Home/ThemeCategorySection/index.tsx +++ b/src/components/features/Home/ThemeCategorySection/index.tsx @@ -1,14 +1,30 @@ import styled from '@emotion/styled'; +import { 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 { getThemes } from '@/libs/api'; import { getDynamicPath } from '@/routes/path'; import { breakpoints } from '@/styles/variants'; +import type { ThemesData } from '@/types'; import { ThemeCategoryItem } from './ThemeCategoryItem'; export const ThemeCategorySection = () => { + const [themes, setThemes] = useState([]); + useEffect(() => { + const fetchThemes = async () => { + try { + const data = await getThemes(); + setThemes(data.themes); + } catch (error) { + console.error('Failed to fetch themes:', error); + } + }; + + fetchThemes(); + }, []); return ( @@ -18,78 +34,11 @@ export const ThemeCategorySection = () => { md: 6, }} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {themes.map((theme) => ( + + + + ))} diff --git a/src/libs/api.tsx b/src/libs/api.tsx new file mode 100644 index 00000000..bb71422f --- /dev/null +++ b/src/libs/api.tsx @@ -0,0 +1,22 @@ +import axios from 'axios'; + +const API_BASE_URL = 'https://kakao-tech-campus-mock-server.vercel.app'; + +export const getThemes = async () => { + const response = await axios.get(`${API_BASE_URL}/api/v1/themes`); + console.log(response.data); + return response.data; +}; + +// export const getRanking = async (params: { +// targetType?: string; +// rankType?: string; +// }): Promise => { +// const response = await axios.get(`${API_BASE_URL}/ranking-products`, { params }); +// return response.data; +// }; + +export const getTheme = async (themeKey: string) => { + const response = await axios.get(`${API_BASE_URL}/api/v1/themes/${themeKey}/products`); + return response.data; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 9d76b97b..65a34a50 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,11 @@ +export type ThemesData = { + id: number; + key: string; + label: string; + title: string; + imageURL: string; +}; + export type ThemeData = { id: number; key: string; From 0f75d2284e1d45fcba4ac5b5800e90e0dbe62c7c Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Tue, 9 Jul 2024 15:50:30 +0900 Subject: [PATCH 05/17] feat: set theme category section api --- .../Home/GoodsRankingSection/index.tsx | 40 ++++++++++++++++--- src/libs/api.tsx | 19 +++++---- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/components/features/Home/GoodsRankingSection/index.tsx b/src/components/features/Home/GoodsRankingSection/index.tsx index 9464d67c..37e3cb38 100644 --- a/src/components/features/Home/GoodsRankingSection/index.tsx +++ b/src/components/features/Home/GoodsRankingSection/index.tsx @@ -1,10 +1,10 @@ import styled from '@emotion/styled'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Container } from '@/components/common/layouts/Container'; +import { getRankingProducts } from '@/libs/api'; import { breakpoints } from '@/styles/variants'; -import type { RankingFilterOption } from '@/types'; -import { GoodsMockList } from '@/types/mock'; +import type { GoodsData, RankingFilterOption } from '@/types'; import { GoodsRankingFilter } from './Filter'; import { GoodsRankingList } from './List'; @@ -15,14 +15,44 @@ export const GoodsRankingSection = () => { rankType: 'MANY_WISH', }); - // GoodsMockData를 21번 반복 생성 + const [goodsList, setGoodsList] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const fetchRankingProducts = async (filter: RankingFilterOption) => { + try { + setLoading(true); + const data = await getRankingProducts({ + targetType: filter.targetType, + rankType: filter.rankType, + }); + setGoodsList(data.products); + setLoading(false); + } catch (err) { + console.error('Failed to fetch ranking products:', err); + setError(true); + setLoading(false); + } + }; + + useEffect(() => { + fetchRankingProducts(filterOption); + }, [filterOption]); + + if (loading) { + return
Loading...
; + } + + if (error) { + return
Failed to load ranking products.
; + } return ( 실시간 급상승 선물랭킹 - + ); diff --git a/src/libs/api.tsx b/src/libs/api.tsx index bb71422f..d906bb76 100644 --- a/src/libs/api.tsx +++ b/src/libs/api.tsx @@ -4,19 +4,18 @@ const API_BASE_URL = 'https://kakao-tech-campus-mock-server.vercel.app'; export const getThemes = async () => { const response = await axios.get(`${API_BASE_URL}/api/v1/themes`); - console.log(response.data); + return response.data; }; -// export const getRanking = async (params: { -// targetType?: string; -// rankType?: string; -// }): Promise => { -// const response = await axios.get(`${API_BASE_URL}/ranking-products`, { params }); -// return response.data; -// }; +export const getRankingProducts = async (params: { targetType: string; rankType: string }) => { + const response = await axios.get(`${API_BASE_URL}/api/v1/ranking/products`, { params }); + return response.data; +}; -export const getTheme = async (themeKey: string) => { - const response = await axios.get(`${API_BASE_URL}/api/v1/themes/${themeKey}/products`); +export const getTheme = async (themeKey: string, maxResults: number = 20) => { + const response = await axios.get(`${API_BASE_URL}/api/v1/themes/${themeKey}/products`, { + params: { maxResults }, + }); return response.data; }; From 2bb4c212665cd2876a9b20c7b8fb5bc412c074f1 Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Tue, 9 Jul 2024 15:51:35 +0900 Subject: [PATCH 06/17] feat: call theme data api --- .../Theme/ThemeGoodsSection/index.tsx | 10 ++-- .../features/Theme/ThemeHeroSection/index.tsx | 21 +++------ src/pages/Theme/index.tsx | 47 ++++++++++++++++--- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/src/components/features/Theme/ThemeGoodsSection/index.tsx b/src/components/features/Theme/ThemeGoodsSection/index.tsx index 8edbf70e..aee08413 100644 --- a/src/components/features/Theme/ThemeGoodsSection/index.tsx +++ b/src/components/features/Theme/ThemeGoodsSection/index.tsx @@ -4,13 +4,13 @@ import { DefaultGoodsItems } from '@/components/common/GoodsItem/Default'; import { Container } from '@/components/common/layouts/Container'; import { Grid } from '@/components/common/layouts/Grid'; import { breakpoints } from '@/styles/variants'; -import { GoodsMockList } from '@/types/mock'; - +import { GoodsData } from '@/types'; type Props = { - themeKey: string; + goods: GoodsData[]; }; -export const ThemeGoodsSection = ({}: Props) => { +export const ThemeGoodsSection = ({ goods }: Props) => { + console.log(goods); return ( @@ -21,7 +21,7 @@ export const ThemeGoodsSection = ({}: Props) => { }} gap={16} > - {GoodsMockList.map(({ id, imageURL, name, price, brandInfo }) => ( + {goods.map(({ id, imageURL, name, price, brandInfo }) => ( { - const currentTheme = getCurrentTheme(themeKey, ThemeMockList); - - if (!currentTheme) { - return null; - } - - const { backgroundColor, label, title, description } = currentTheme; - +export const ThemeHeroSection = ({ theme }: Props) => { return ( - + - - {title} - {description && {description}} + + {theme.title} + {theme.description && {theme.description}} ); diff --git a/src/pages/Theme/index.tsx b/src/pages/Theme/index.tsx index 4d02e6c1..c98bb346 100644 --- a/src/pages/Theme/index.tsx +++ b/src/pages/Theme/index.tsx @@ -1,22 +1,57 @@ +import { useEffect, useState } from 'react'; import { Navigate, useParams } from 'react-router-dom'; import { ThemeGoodsSection } from '@/components/features/Theme/ThemeGoodsSection'; -import { getCurrentTheme, ThemeHeroSection } from '@/components/features/Theme/ThemeHeroSection'; +import { ThemeHeroSection } from '@/components/features/Theme/ThemeHeroSection'; +import { getTheme, getThemes } from '@/libs/api'; import { RouterPath } from '@/routes/path'; -import { ThemeMockList } from '@/types/mock'; +import type { GoodsData, ThemeData } from '@/types'; export const ThemePage = () => { const { themeKey = '' } = useParams<{ themeKey: string }>(); - const currentTheme = getCurrentTheme(themeKey, ThemeMockList); + const [label, setLabel] = useState(null); + const [goods, setGoods] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); - if (!currentTheme) { + useEffect(() => { + const fetchTheme = async () => { + try { + const themes = await getThemes(); + const currentTheme = themes.themes.find((theme: ThemeData) => theme.key === themeKey); + + if (!currentTheme) { + setError(true); + return; + } + + const data = await getTheme(themeKey); + setLabel(currentTheme); // Assuming the API returns theme data under `data.theme` + setGoods(data.products); // Assuming the API returns product data under `data.products` + setLoading(false); + console.log(data); + } catch (err) { + console.error('Failed to fetch theme data:', err); + setError(true); + setLoading(false); + } + }; + + fetchTheme(); + }, [themeKey]); + + if (loading) { + return
Loading...
; + } + + if (error || !label || !goods) { return ; } return ( <> - - + + ); }; From 7d7543c7918b96af0552331bddd4f4f4df21b8ac Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Tue, 9 Jul 2024 19:57:46 +0900 Subject: [PATCH 07/17] feat: add Loading and Error components --- src/components/common/Error/Error.tsx | 7 ++++++ src/components/common/Loading/Loading.tsx | 29 +++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/components/common/Error/Error.tsx create mode 100644 src/components/common/Loading/Loading.tsx diff --git a/src/components/common/Error/Error.tsx b/src/components/common/Error/Error.tsx new file mode 100644 index 00000000..a824b038 --- /dev/null +++ b/src/components/common/Error/Error.tsx @@ -0,0 +1,7 @@ +export const ErrorMessage = () => { + return
에러가 발생했습니다 !!
; +}; + +export const EmptyMessage = () => { + return
데이터가 없는데용?
; +}; diff --git a/src/components/common/Loading/Loading.tsx b/src/components/common/Loading/Loading.tsx new file mode 100644 index 00000000..dea3a8d5 --- /dev/null +++ b/src/components/common/Loading/Loading.tsx @@ -0,0 +1,29 @@ +/** @jsxImportSource @emotion/react */ +import { css, keyframes } from '@emotion/react'; + +const spin = keyframes` + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +`; + +const spinnerStyle = css` + margin: 0 auto; + border: 6px solid #ccc; + border-top: 6px solid #1d3f72; + border-radius: 50%; + width: 50px; + height: 50px; + animation: ${spin} 1s linear infinite; +`; + +export const LoadingSpinner = () => { + return
; +}; + +export const LoadingMessage = () => { + return
로딩 중
; +}; From 6695e16739634d276ce36c3fd4a7a0f7ad567a4d Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Tue, 9 Jul 2024 19:58:04 +0900 Subject: [PATCH 08/17] fix: change domain --- src/libs/api.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/api.tsx b/src/libs/api.tsx index d906bb76..e287b988 100644 --- a/src/libs/api.tsx +++ b/src/libs/api.tsx @@ -1,6 +1,6 @@ import axios from 'axios'; -const API_BASE_URL = 'https://kakao-tech-campus-mock-server.vercel.app'; +const API_BASE_URL = 'https://react-gift-mock-api-nnoonjy.vercel.app'; export const getThemes = async () => { const response = await axios.get(`${API_BASE_URL}/api/v1/themes`); From b572244e42f8b5f46cc95c1874ecf7e38708825c Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Tue, 9 Jul 2024 19:58:40 +0900 Subject: [PATCH 09/17] feat: put message when loading or error --- .../Home/GoodsRankingSection/index.tsx | 63 +++++++++++++------ 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/src/components/features/Home/GoodsRankingSection/index.tsx b/src/components/features/Home/GoodsRankingSection/index.tsx index 37e3cb38..3e87486b 100644 --- a/src/components/features/Home/GoodsRankingSection/index.tsx +++ b/src/components/features/Home/GoodsRankingSection/index.tsx @@ -1,7 +1,9 @@ import styled from '@emotion/styled'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { EmptyMessage, ErrorMessage } from '@/components/common/Error/Error'; import { Container } from '@/components/common/layouts/Container'; +import { LoadingMessage } from '@/components/common/Loading/Loading'; import { getRankingProducts } from '@/libs/api'; import { breakpoints } from '@/styles/variants'; import type { GoodsData, RankingFilterOption } from '@/types'; @@ -18,8 +20,9 @@ export const GoodsRankingSection = () => { const [goodsList, setGoodsList] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); + const [isEmpty, setIsEmpty] = useState(false); - const fetchRankingProducts = async (filter: RankingFilterOption) => { + const fetchRankingProducts = useCallback(async (filter: RankingFilterOption) => { try { setLoading(true); const data = await getRankingProducts({ @@ -27,45 +30,60 @@ export const GoodsRankingSection = () => { rankType: filter.rankType, }); setGoodsList(data.products); + setIsEmpty(data.products.length === 0); setLoading(false); + setError(false); // Reset error state on successful fetch } catch (err) { console.error('Failed to fetch ranking products:', err); setError(true); setLoading(false); } - }; + }, []); useEffect(() => { fetchRankingProducts(filterOption); - }, [filterOption]); - - if (loading) { - return
Loading...
; - } - - if (error) { - return
Failed to load ranking products.
; - } + }, [fetchRankingProducts, filterOption]); return ( - - 실시간 급상승 선물랭킹 - - - + + + 실시간 급상승 선물랭킹 + + {loading ? ( + + + + ) : isEmpty ? ( + + + + ) : error ? ( + + + + ) : ( + + )} + + ); }; const Wrapper = styled.section` padding: 0 16px 32px; - @media screen and (min-width: ${breakpoints.sm}) { padding: 0 16px 80px; } `; +const StyledContainer = styled(Container)` + display: flex; + flex-direction: column; + align-items: center; +`; + const Title = styled.h2` color: #000; width: 100%; @@ -80,3 +98,12 @@ const Title = styled.h2` line-height: 50px; } `; + +const CenteredContent = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + padding: 20px 0; +`; From 6176eb927c8713ad89a427cba52ffb7e7db72bdc Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Wed, 10 Jul 2024 01:06:36 +0900 Subject: [PATCH 10/17] feat: put loading spinner --- src/components/features/Home/GoodsRankingSection/index.tsx | 1 + src/pages/Theme/index.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/features/Home/GoodsRankingSection/index.tsx b/src/components/features/Home/GoodsRankingSection/index.tsx index 3e87486b..7afd2c31 100644 --- a/src/components/features/Home/GoodsRankingSection/index.tsx +++ b/src/components/features/Home/GoodsRankingSection/index.tsx @@ -33,6 +33,7 @@ export const GoodsRankingSection = () => { setIsEmpty(data.products.length === 0); setLoading(false); setError(false); // Reset error state on successful fetch + console.log(data); } catch (err) { console.error('Failed to fetch ranking products:', err); setError(true); diff --git a/src/pages/Theme/index.tsx b/src/pages/Theme/index.tsx index c98bb346..fd8e944a 100644 --- a/src/pages/Theme/index.tsx +++ b/src/pages/Theme/index.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Navigate, useParams } from 'react-router-dom'; +import { LoadingSpinner } from '@/components/common/Loading/Loading'; import { ThemeGoodsSection } from '@/components/features/Theme/ThemeGoodsSection'; import { ThemeHeroSection } from '@/components/features/Theme/ThemeHeroSection'; import { getTheme, getThemes } from '@/libs/api'; @@ -41,7 +42,7 @@ export const ThemePage = () => { }, [themeKey]); if (loading) { - return
Loading...
; + return ; } if (error || !label || !goods) { From 78af01af10afd0c2479d7e20c776051bde4973f9 Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Wed, 10 Jul 2024 16:00:22 +0900 Subject: [PATCH 11/17] feat: change error message in ranking session --- src/components/common/Error/Error.tsx | 4 +- .../Home/GoodsRankingSection/index.tsx | 29 +++--- src/libs/api.tsx | 91 +++++++++++++++++-- 3 files changed, 96 insertions(+), 28 deletions(-) diff --git a/src/components/common/Error/Error.tsx b/src/components/common/Error/Error.tsx index a824b038..44533224 100644 --- a/src/components/common/Error/Error.tsx +++ b/src/components/common/Error/Error.tsx @@ -1,5 +1,5 @@ -export const ErrorMessage = () => { - return
에러가 발생했습니다 !!
; +export const ErrorMessage = (message: { message: string }) => { + return
{message.message}
; }; export const EmptyMessage = () => { diff --git a/src/components/features/Home/GoodsRankingSection/index.tsx b/src/components/features/Home/GoodsRankingSection/index.tsx index 7afd2c31..2281fef7 100644 --- a/src/components/features/Home/GoodsRankingSection/index.tsx +++ b/src/components/features/Home/GoodsRankingSection/index.tsx @@ -19,26 +19,23 @@ export const GoodsRankingSection = () => { const [goodsList, setGoodsList] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); + const [error, setError] = useState(''); const [isEmpty, setIsEmpty] = useState(false); const fetchRankingProducts = useCallback(async (filter: RankingFilterOption) => { - try { - setLoading(true); - const data = await getRankingProducts({ - targetType: filter.targetType, - rankType: filter.rankType, - }); + setLoading(true); + setError(''); + const data = await getRankingProducts({ + targetType: filter.targetType, + rankType: filter.rankType, + }); + if (typeof data === 'string') { + setError(data); + } else { setGoodsList(data.products); setIsEmpty(data.products.length === 0); - setLoading(false); - setError(false); // Reset error state on successful fetch - console.log(data); - } catch (err) { - console.error('Failed to fetch ranking products:', err); - setError(true); - setLoading(false); } + setLoading(false); }, []); useEffect(() => { @@ -59,9 +56,9 @@ export const GoodsRankingSection = () => { - ) : error ? ( + ) : error != '' ? ( - + ) : ( diff --git a/src/libs/api.tsx b/src/libs/api.tsx index e287b988..c37274e2 100644 --- a/src/libs/api.tsx +++ b/src/libs/api.tsx @@ -1,21 +1,92 @@ -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; const API_BASE_URL = 'https://react-gift-mock-api-nnoonjy.vercel.app'; -export const getThemes = async () => { - const response = await axios.get(`${API_BASE_URL}/api/v1/themes`); +// Axios 인스턴스 생성 +const apiClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 10000, // 타임아웃 설정 +}); + +// Axios 에러 처리 함수 +const setError = (error: AxiosError) => { + if (error.response) { + // 서버가 응답을 보낸 경우 (status code가 2xx가 아닌 경우) + const status = error.response.status; + switch (status) { + case 400: + return '잘못된 요청입니다.'; + case 401: + return '인증되지 않은 사용자입니다.'; + case 403: + return '접근이 금지되었습니다.'; + case 404: + return '요청한 자원을 찾을 수 없습니다.'; + case 500: + return '서버에 오류가 발생했습니다.'; + default: + return `알 수 없는 오류가 발생했습니다. 상태 코드: ${status}`; + } + } else if (error.request) { + // 요청이 전송되었으나 응답을 받지 못한 경우 + return '서버로부터 응답을 받지 못했습니다.'; + } else { + // 요청을 설정하는 중에 오류가 발생한 경우 + return '요청을 설정하는 중에 오류가 발생했습니다.'; + } +}; - return response.data; +export const getThemes = async () => { + try { + const response = await apiClient.get('/api/v1/themes'); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + // AxiosError 타입으로 캐스팅하여 처리 + const axiosError = error as AxiosError; + return axiosError.response?.status; + } else { + // 기타 다른 에러 처리 + console.error('getThemes 요청 중 오류 발생:', error); + throw error; + } + } }; export const getRankingProducts = async (params: { targetType: string; rankType: string }) => { - const response = await axios.get(`${API_BASE_URL}/api/v1/ranking/products`, { params }); - return response.data; + try { + const response = await apiClient.get('/api/v1/ranking/products', { params }); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + // AxiosError 타입으로 캐스팅하여 처리 + const axiosError = error as AxiosError; + console.error('getRankingProducts 요청 중 오류 발생:', setError(axiosError)); + return setError(axiosError); + } else { + // 기타 다른 에러 처리 + console.error('getRankingProducts 요청 중 오류 발생:', error); + throw error; + } + } }; export const getTheme = async (themeKey: string, maxResults: number = 20) => { - const response = await axios.get(`${API_BASE_URL}/api/v1/themes/${themeKey}/products`, { - params: { maxResults }, - }); - return response.data; + try { + const response = await apiClient.get(`/api/v1/themes/${themeKey}/products`, { + params: { maxResults }, + }); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + // AxiosError 타입으로 캐스팅하여 처리 + const axiosError = error as AxiosError; + console.error('getTheme 요청 중 오류 발생:', setError(axiosError)); + throw axiosError; + } else { + // 기타 다른 에러 처리 + console.error('getTheme 요청 중 오류 발생:', error); + throw error; + } + } }; From 9fd45a7a92a877cdc8a677d7431927d2732ee071 Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Thu, 11 Jul 2024 15:28:28 +0900 Subject: [PATCH 12/17] refactor: divide the api section --- .../Theme/ThemeGoodsSection/index.tsx | 65 ++++++++++++-- .../features/Theme/ThemeHeroSection/index.tsx | 86 +++++++++++++++++-- src/libs/api.tsx | 5 +- src/pages/Theme/index.tsx | 51 +---------- 4 files changed, 143 insertions(+), 64 deletions(-) diff --git a/src/components/features/Theme/ThemeGoodsSection/index.tsx b/src/components/features/Theme/ThemeGoodsSection/index.tsx index aee08413..bfc7a467 100644 --- a/src/components/features/Theme/ThemeGoodsSection/index.tsx +++ b/src/components/features/Theme/ThemeGoodsSection/index.tsx @@ -1,16 +1,60 @@ import styled from '@emotion/styled'; +import { useEffect, useState } from 'react'; +import { Navigate, useParams } from 'react-router-dom'; +import { ErrorMessage } from '@/components/common/Error/Error'; import { DefaultGoodsItems } from '@/components/common/GoodsItem/Default'; import { Container } from '@/components/common/layouts/Container'; import { Grid } from '@/components/common/layouts/Grid'; +import { LoadingSpinner } from '@/components/common/Loading/Loading'; +import { getTheme } from '@/libs/api'; +import { RouterPath } from '@/routes/path'; import { breakpoints } from '@/styles/variants'; -import { GoodsData } from '@/types'; -type Props = { - goods: GoodsData[]; -}; +import type { GoodsData } from '@/types'; + +export const ThemeGoodsSection = () => { + const { themeKey = '' } = useParams<{ themeKey: string }>(); + const [goods, setGoods] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const fetchGoodsData = async () => { + setLoading(true); + setError(''); + try { + const data = await getTheme(themeKey); + + if (typeof data === 'string') { + setError(data); + } else { + setGoods(data.products); + } + } catch (err) { + setError('데이터를 가져오는 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + }; + + fetchGoodsData(); + }, [themeKey]); + + if (loading) { + return ; + } -export const ThemeGoodsSection = ({ goods }: Props) => { - console.log(goods); + if (error) { + return ( + + + + ); + } + + if (!goods) { + return ; + } return ( @@ -44,3 +88,12 @@ const Wrapper = styled.section` padding: 40px 16px 360px; } `; + +const CenteredContent = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + padding: 20px 0; +`; diff --git a/src/components/features/Theme/ThemeHeroSection/index.tsx b/src/components/features/Theme/ThemeHeroSection/index.tsx index 4d83f47b..8b119854 100644 --- a/src/components/features/Theme/ThemeHeroSection/index.tsx +++ b/src/components/features/Theme/ThemeHeroSection/index.tsx @@ -1,20 +1,83 @@ import styled from '@emotion/styled'; +import { useEffect, useState } from 'react'; +import { Navigate, useParams } from 'react-router-dom'; +import { ErrorMessage } from '@/components/common/Error/Error'; import { Container } from '@/components/common/layouts/Container'; +import { LoadingSpinner } from '@/components/common/Loading/Loading'; +import { getThemes } from '@/libs/api'; +import { RouterPath } from '@/routes/path'; import { breakpoints } from '@/styles/variants'; import type { ThemeData } from '@/types'; -type Props = { - theme: ThemeData; -}; +export const ThemeHeroSection = () => { + const { themeKey = '' } = useParams<{ themeKey: string }>(); + const [theme, setTheme] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const fetchTheme = async () => { + setLoading(true); + setError(''); + try { + const themes = await getThemes(); + const currentTheme = themes.themes.find((label: ThemeData) => label.key === themeKey); + + if (!currentTheme) { + setError('theme을 찾을 수 없음'); + setLoading(false); + return; + } + + if (typeof currentTheme === 'string') { + setError(currentTheme); + } else { + setTheme(currentTheme); + } + } catch (err) { + setError('데이터를 가져오는 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + }; + + fetchTheme(); + }, [themeKey]); + + if (loading) { + return ; + } -export const ThemeHeroSection = ({ theme }: Props) => { + if (error) { + return ( + + + + ); + } + + if (!theme) { + return ; + } return ( - - {theme.title} - {theme.description && {theme.description}} + {loading ? ( + + + + ) : error != '' ? ( + + + + ) : ( + + + {theme.title} + {theme.description && {theme.description}} + + )} ); @@ -74,6 +137,15 @@ const Description = styled.p` } `; +const CenteredContent = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + padding: 20px 0; +`; + export const getCurrentTheme = (themeKey: string, themeList: ThemeData[]) => { return themeList.find((theme) => theme.key === themeKey); }; diff --git a/src/libs/api.tsx b/src/libs/api.tsx index c37274e2..d597ec31 100644 --- a/src/libs/api.tsx +++ b/src/libs/api.tsx @@ -44,7 +44,8 @@ export const getThemes = async () => { if (axios.isAxiosError(error)) { // AxiosError 타입으로 캐스팅하여 처리 const axiosError = error as AxiosError; - return axiosError.response?.status; + console.error('getThemes 요청 중 오류 발생:', setError(axiosError)); + return setError(axiosError); } else { // 기타 다른 에러 처리 console.error('getThemes 요청 중 오류 발생:', error); @@ -82,7 +83,7 @@ export const getTheme = async (themeKey: string, maxResults: number = 20) => { // AxiosError 타입으로 캐스팅하여 처리 const axiosError = error as AxiosError; console.error('getTheme 요청 중 오류 발생:', setError(axiosError)); - throw axiosError; + return setError(axiosError); } else { // 기타 다른 에러 처리 console.error('getTheme 요청 중 오류 발생:', error); diff --git a/src/pages/Theme/index.tsx b/src/pages/Theme/index.tsx index fd8e944a..46f9b5ae 100644 --- a/src/pages/Theme/index.tsx +++ b/src/pages/Theme/index.tsx @@ -1,58 +1,11 @@ -import { useEffect, useState } from 'react'; -import { Navigate, useParams } from 'react-router-dom'; - -import { LoadingSpinner } from '@/components/common/Loading/Loading'; import { ThemeGoodsSection } from '@/components/features/Theme/ThemeGoodsSection'; import { ThemeHeroSection } from '@/components/features/Theme/ThemeHeroSection'; -import { getTheme, getThemes } from '@/libs/api'; -import { RouterPath } from '@/routes/path'; -import type { GoodsData, ThemeData } from '@/types'; export const ThemePage = () => { - const { themeKey = '' } = useParams<{ themeKey: string }>(); - const [label, setLabel] = useState(null); - const [goods, setGoods] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - - useEffect(() => { - const fetchTheme = async () => { - try { - const themes = await getThemes(); - const currentTheme = themes.themes.find((theme: ThemeData) => theme.key === themeKey); - - if (!currentTheme) { - setError(true); - return; - } - - const data = await getTheme(themeKey); - setLabel(currentTheme); // Assuming the API returns theme data under `data.theme` - setGoods(data.products); // Assuming the API returns product data under `data.products` - setLoading(false); - console.log(data); - } catch (err) { - console.error('Failed to fetch theme data:', err); - setError(true); - setLoading(false); - } - }; - - fetchTheme(); - }, [themeKey]); - - if (loading) { - return ; - } - - if (error || !label || !goods) { - return ; - } - return ( <> - - + + ); }; From c787e0795028caa81a58635a5138b0029ad12b30 Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Fri, 12 Jul 2024 02:30:37 +0900 Subject: [PATCH 13/17] feat: add infiniteQuery --- package-lock.json | 138 +++++++++++++++--- package.json | 2 + src/components/common/GoodsItem/Default.tsx | 41 +++--- .../Theme/ThemeGoodsSection/index.tsx | 113 +++++++++----- src/index.tsx | 7 +- 5 files changed, 220 insertions(+), 81 deletions(-) diff --git a/package-lock.json b/package-lock.json index 74216d7d..8f7f8ed1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "dependencies": { "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", + "@tanstack/react-query": "^5.51.1", "axios": "^1.7.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-query": "^3.39.3", "react-router-dom": "^6.22.1" }, "devDependencies": { @@ -9882,6 +9884,30 @@ "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", "dev": true }, + "node_modules/@tanstack/query-core": { + "version": "5.51.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.1.tgz", + "integrity": "sha512-fJBMQMpo8/KSsWW5ratJR5+IFr7YNJ3K2kfP9l5XObYHsgfVy1w3FJUWU4FT2fj7+JMaEg33zOcNDBo0LMwHnw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.51.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.1.tgz", + "integrity": "sha512-s47HKFnQ4HOJAHoIiXcpna/roMMPZJPy6fJ6p4ZNVn8+/onlLBEDd1+xc8OnDuwgvecqkZD7Z2mnSRbcWefrKw==", + "dependencies": { + "@tanstack/query-core": "5.51.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", @@ -12676,8 +12702,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", @@ -12737,7 +12762,6 @@ "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "dev": true, "engines": { "node": ">=0.6" } @@ -12880,6 +12904,21 @@ "node": ">=8" } }, + "node_modules/broadcast-channel": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz", + "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "detect-node": "^2.1.0", + "js-sha3": "0.8.0", + "microseconds": "0.2.0", + "nano-time": "1.0.0", + "oblivious-set": "1.0.0", + "rimraf": "3.0.2", + "unload": "2.2.0" + } + }, "node_modules/browser-assert": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/browser-assert/-/browser-assert-1.2.1.tgz", @@ -13608,8 +13647,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concat-stream": { "version": "1.6.2", @@ -15543,8 +15581,7 @@ "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" }, "node_modules/detect-node-es": { "version": "1.1.0", @@ -18720,8 +18757,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", @@ -19746,7 +19782,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -19755,8 +19790,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "1.3.8", @@ -23856,6 +23890,11 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -24632,6 +24671,15 @@ "react": ">= 0.14.0" } }, + "node_modules/match-sorter": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz", + "integrity": "sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==", + "dependencies": { + "@babel/runtime": "^7.23.8", + "remove-accents": "0.5.0" + } + }, "node_modules/mdast-util-definitions": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz", @@ -24734,6 +24782,11 @@ "node": ">=8.6" } }, + "node_modules/microseconds": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz", + "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==" + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -24974,6 +25027,14 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nano-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz", + "integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==", + "dependencies": { + "big-integer": "^1.6.16" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -25518,6 +25579,11 @@ "integrity": "sha512-eJJDYkhJFFbBBAxeh8xW+weHlkI28n2ZdQV/J/DNfWfSKlGEf2xcfAbZTv3riEXHAhL9SVOTs2pRmXiSTf78xg==", "dev": true }, + "node_modules/oblivious-set": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz", + "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==" + }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -25555,7 +25621,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -25839,7 +25904,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -28274,6 +28338,31 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-query": { + "version": "3.39.3", + "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.3.tgz", + "integrity": "sha512-nLfLz7GiohKTJDuT4us4X3h/8unOh+00MLb2yJoGTPjxKs2bc1iDhkNx2bd5MKklXnOD3NrVZ+J2UXujA5In4g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "broadcast-channel": "^3.4.1", + "match-sorter": "^6.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -28944,6 +29033,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -29138,7 +29232,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -29153,7 +29246,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -29163,7 +29255,6 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -29183,7 +29274,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -31845,6 +31935,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unload": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz", + "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==", + "dependencies": { + "@babel/runtime": "^7.6.2", + "detect-node": "^2.0.4" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -33316,8 +33415,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/write-file-atomic": { "version": "4.0.2", diff --git a/package.json b/package.json index f1f65cfa..a1a4b2a0 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,11 @@ "dependencies": { "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", + "@tanstack/react-query": "^5.51.1", "axios": "^1.7.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-query": "^3.39.3", "react-router-dom": "^6.22.1" }, "devDependencies": { diff --git a/src/components/common/GoodsItem/Default.tsx b/src/components/common/GoodsItem/Default.tsx index 99e3753e..4e4991dd 100644 --- a/src/components/common/GoodsItem/Default.tsx +++ b/src/components/common/GoodsItem/Default.tsx @@ -1,4 +1,5 @@ import styled from '@emotion/styled'; +import React from 'react'; import { Image } from '@/components/common/Image'; @@ -9,27 +10,23 @@ export type DefaultGoodsItemsProps = { amount: number; } & React.HTMLAttributes; -export const DefaultGoodsItems = ({ - imageSrc, - subtitle, - title, - amount, - ...props -}: DefaultGoodsItemsProps) => { - return ( - - {`${title} - - {subtitle} - {title} - - {amount} - - - - - ); -}; +export const DefaultGoodsItems = React.forwardRef( + ({ imageSrc, subtitle, title, amount, ...props }, ref) => { + return ( + + {`${title} + + {subtitle} + {title} + + {amount} + + + + + ); + }, +); const Wrapper = styled.div` width: 100%; @@ -77,3 +74,5 @@ const Amount = styled.p` font-weight: 400; } `; + +export default DefaultGoodsItems; diff --git a/src/components/features/Theme/ThemeGoodsSection/index.tsx b/src/components/features/Theme/ThemeGoodsSection/index.tsx index bfc7a467..6f6c7f13 100644 --- a/src/components/features/Theme/ThemeGoodsSection/index.tsx +++ b/src/components/features/Theme/ThemeGoodsSection/index.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; -import { useEffect, useState } from 'react'; -import { Navigate, useParams } from 'react-router-dom'; +import { useCallback, useRef } from 'react'; +import { useInfiniteQuery } from 'react-query'; +import { useParams } from 'react-router-dom'; import { ErrorMessage } from '@/components/common/Error/Error'; import { DefaultGoodsItems } from '@/components/common/GoodsItem/Default'; @@ -8,53 +9,62 @@ import { Container } from '@/components/common/layouts/Container'; import { Grid } from '@/components/common/layouts/Grid'; import { LoadingSpinner } from '@/components/common/Loading/Loading'; import { getTheme } from '@/libs/api'; -import { RouterPath } from '@/routes/path'; import { breakpoints } from '@/styles/variants'; -import type { GoodsData } from '@/types'; export const ThemeGoodsSection = () => { const { themeKey = '' } = useParams<{ themeKey: string }>(); - const [goods, setGoods] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - useEffect(() => { - const fetchGoodsData = async () => { - setLoading(true); - setError(''); - try { - const data = await getTheme(themeKey); + const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useInfiniteQuery( + ['themeGoods', themeKey], + ({ pageParam = 1 }) => getTheme(themeKey, pageParam * 20), + { + getNextPageParam: (lastPage, pages) => { + if (!lastPage || !lastPage.products || lastPage.products.length < 20) { + return undefined; + } + return pages.length + 1; + }, + }, + ); - if (typeof data === 'string') { - setError(data); - } else { - setGoods(data.products); + const observer = useRef(null); + const lastGoodsElementRef = useCallback( + (node: HTMLElement | null) => { + if (isFetchingNextPage) return; + if (observer.current) observer.current.disconnect(); + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasNextPage) { + fetchNextPage(); } - } catch (err) { - setError('데이터를 가져오는 중 오류가 발생했습니다.'); - } finally { - setLoading(false); - } - }; - - fetchGoodsData(); - }, [themeKey]); + }); + if (node) observer.current.observe(node); + }, + [isFetchingNextPage, fetchNextPage, hasNextPage], + ); - if (loading) { + if (isLoading) { return ; } if (error) { return ( - + ); } - if (!goods) { - return ; + if (!data || !data.pages) { + return ( + + + + ); } + + const goods = data.pages.flatMap((page) => page.products) ?? []; + return ( @@ -65,16 +75,39 @@ export const ThemeGoodsSection = () => { }} gap={16} > - {goods.map(({ id, imageURL, name, price, brandInfo }) => ( - - ))} + {goods.map((good, index) => { + if (!good) + return ( + + + + ); // good이 undefined인지 확인 + const { id, imageURL, name, price, brandInfo } = good; + if (goods.length === index + 1) { + return ( + + ); + } else { + return ( + + ); + } + })} + {isFetchingNextPage && } ); @@ -97,3 +130,5 @@ const CenteredContent = styled.div` height: 100%; padding: 20px 0; `; + +export default ThemeGoodsSection; diff --git a/src/index.tsx b/src/index.tsx index ab5f7ad6..45cab63c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,12 +2,17 @@ import '@/styles'; import React from 'react'; import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from 'react-query'; import App from '@/App'; +const queryClient = new QueryClient(); + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( - + + + , ); From a3f44cff5bf36085cf33ebf6219f3cb51617cfa5 Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Fri, 12 Jul 2024 14:39:40 +0900 Subject: [PATCH 14/17] feat: change into React-Query --- .../Home/GoodsRankingSection/List.tsx | 33 ++++++--- .../Home/GoodsRankingSection/index.tsx | 73 ++++++++++++------- .../Theme/ThemeGoodsSection/index.tsx | 49 ++++++------- 3 files changed, 90 insertions(+), 65 deletions(-) diff --git a/src/components/features/Home/GoodsRankingSection/List.tsx b/src/components/features/Home/GoodsRankingSection/List.tsx index 27bd332c..f03b982c 100644 --- a/src/components/features/Home/GoodsRankingSection/List.tsx +++ b/src/components/features/Home/GoodsRankingSection/List.tsx @@ -14,7 +14,14 @@ type Props = { export const GoodsRankingList = ({ goodsList }: Props) => { const [hasMore, setHasMore] = useState(false); - const currentGoodsList = hasMore ? goodsList : goodsList.slice(0, 6); + // 더보기 버튼을 표시할 아이템 개수 + const displayCount = 6; + + // 현재 보여줄 아이템 리스트 + const currentGoodsList = hasMore ? goodsList : goodsList.slice(0, displayCount); + + // 더보기 버튼을 표시할 조건 + const showMoreButton = goodsList.length > displayCount; return ( @@ -37,17 +44,19 @@ export const GoodsRankingList = ({ goodsList }: Props) => { /> ))} - - - + {showMoreButton && ( + + + + )} ); }; diff --git a/src/components/features/Home/GoodsRankingSection/index.tsx b/src/components/features/Home/GoodsRankingSection/index.tsx index 2281fef7..db7f6bf7 100644 --- a/src/components/features/Home/GoodsRankingSection/index.tsx +++ b/src/components/features/Home/GoodsRankingSection/index.tsx @@ -1,12 +1,13 @@ import styled from '@emotion/styled'; -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useQuery } from 'react-query'; import { EmptyMessage, ErrorMessage } from '@/components/common/Error/Error'; import { Container } from '@/components/common/layouts/Container'; import { LoadingMessage } from '@/components/common/Loading/Loading'; import { getRankingProducts } from '@/libs/api'; import { breakpoints } from '@/styles/variants'; -import type { GoodsData, RankingFilterOption } from '@/types'; +import type { RankingFilterOption } from '@/types'; import { GoodsRankingFilter } from './Filter'; import { GoodsRankingList } from './List'; @@ -17,38 +18,52 @@ export const GoodsRankingSection = () => { rankType: 'MANY_WISH', }); - const [goodsList, setGoodsList] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); const [isEmpty, setIsEmpty] = useState(false); + const [errorState, setErrorState] = useState(''); - const fetchRankingProducts = useCallback(async (filter: RankingFilterOption) => { - setLoading(true); - setError(''); - const data = await getRankingProducts({ - targetType: filter.targetType, - rankType: filter.rankType, - }); - if (typeof data === 'string') { - setError(data); - } else { - setGoodsList(data.products); - setIsEmpty(data.products.length === 0); - } - setLoading(false); - }, []); + const { + data: rankingData, // API에서 불러온 데이터는 이 객체에 저장됩니다. - useEffect(() => { - fetchRankingProducts(filterOption); - }, [fetchRankingProducts, filterOption]); + isLoading, + isFetching, + refetch, + } = useQuery(['rankingProducts', filterOption], () => getRankingProducts(filterOption), { + keepPreviousData: true, + + onError: (err) => { + setErrorState((err as Error).message); + }, + onSuccess: (data) => { + // data가 유효한지 확인 + if (data || data.products) { + console.log(data); + setErrorState(''); + if (typeof data === 'string') { + setErrorState(data); + } else { + setIsEmpty(data.products.length === 0); + } + } else { + setIsEmpty(true); + } + }, + }); + + const handleFilterChange = (newFilterOption: RankingFilterOption) => { + setFilterOption(newFilterOption); + refetch(); + }; return ( 실시간 급상승 선물랭킹 - - {loading ? ( + + {isLoading || isFetching ? ( @@ -56,12 +71,12 @@ export const GoodsRankingSection = () => { - ) : error != '' ? ( + ) : errorState ? ( - + ) : ( - + )} @@ -105,3 +120,5 @@ const CenteredContent = styled.div` height: 100%; padding: 20px 0; `; + +export default GoodsRankingSection; diff --git a/src/components/features/Theme/ThemeGoodsSection/index.tsx b/src/components/features/Theme/ThemeGoodsSection/index.tsx index 6f6c7f13..419d017c 100644 --- a/src/components/features/Theme/ThemeGoodsSection/index.tsx +++ b/src/components/features/Theme/ThemeGoodsSection/index.tsx @@ -14,19 +14,18 @@ import { breakpoints } from '@/styles/variants'; export const ThemeGoodsSection = () => { const { themeKey = '' } = useParams<{ themeKey: string }>(); - const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = - useInfiniteQuery( - ['themeGoods', themeKey], - ({ pageParam = 1 }) => getTheme(themeKey, pageParam * 20), - { - getNextPageParam: (lastPage, pages) => { - if (!lastPage || !lastPage.products || lastPage.products.length < 20) { - return undefined; - } - return pages.length + 1; - }, + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery( + ['themeGoods', themeKey], + ({ pageParam = 1 }) => getTheme(themeKey, pageParam * 20), + { + getNextPageParam: (lastPage, pages) => { + if (!lastPage.products || lastPage.products.length < 20) { + return undefined; + } + return pages.length + 1; }, - ); + }, + ); const observer = useRef(null); const lastGoodsElementRef = useCallback( @@ -46,24 +45,29 @@ export const ThemeGoodsSection = () => { if (isLoading) { return ; } - - if (error) { + if (!data || !data.pages) { return ( - + ); } - if (!data || !data.pages) { + const goods = data.pages.flatMap((page) => page.products) ?? []; + if (typeof data?.pages[0] === 'string') { return ( - + + + ); + } + if (goods.length === 0) { + return ( + + ); } - - const goods = data.pages.flatMap((page) => page.products) ?? []; return ( @@ -76,12 +80,7 @@ export const ThemeGoodsSection = () => { gap={16} > {goods.map((good, index) => { - if (!good) - return ( - - - - ); // good이 undefined인지 확인 + if (!good) return null; // good이 undefined인지 확인 const { id, imageURL, name, price, brandInfo } = good; if (goods.length === index + 1) { return ( From c71fb8ecc06ecb39dac2dfd959bcb592883ff10c Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Fri, 12 Jul 2024 15:22:43 +0900 Subject: [PATCH 15/17] feat: change into React-Query --- .../Home/GoodsRankingSection/index.tsx | 6 +- .../features/Theme/ThemeHeroSection/index.tsx | 99 +++++++++---------- 2 files changed, 49 insertions(+), 56 deletions(-) diff --git a/src/components/features/Home/GoodsRankingSection/index.tsx b/src/components/features/Home/GoodsRankingSection/index.tsx index db7f6bf7..a72b6d81 100644 --- a/src/components/features/Home/GoodsRankingSection/index.tsx +++ b/src/components/features/Home/GoodsRankingSection/index.tsx @@ -30,9 +30,6 @@ export const GoodsRankingSection = () => { } = useQuery(['rankingProducts', filterOption], () => getRankingProducts(filterOption), { keepPreviousData: true, - onError: (err) => { - setErrorState((err as Error).message); - }, onSuccess: (data) => { // data가 유효한지 확인 if (data || data.products) { @@ -47,6 +44,9 @@ export const GoodsRankingSection = () => { setIsEmpty(true); } }, + onError: (err) => { + setErrorState((err as Error).message); + }, }); const handleFilterChange = (newFilterOption: RankingFilterOption) => { diff --git a/src/components/features/Theme/ThemeHeroSection/index.tsx b/src/components/features/Theme/ThemeHeroSection/index.tsx index 8b119854..90caea61 100644 --- a/src/components/features/Theme/ThemeHeroSection/index.tsx +++ b/src/components/features/Theme/ThemeHeroSection/index.tsx @@ -1,83 +1,76 @@ import styled from '@emotion/styled'; -import { useEffect, useState } from 'react'; -import { Navigate, useParams } from 'react-router-dom'; +import { useState } from 'react'; +import { useQuery } from 'react-query'; +import { useParams } from 'react-router-dom'; import { ErrorMessage } from '@/components/common/Error/Error'; import { Container } from '@/components/common/layouts/Container'; import { LoadingSpinner } from '@/components/common/Loading/Loading'; import { getThemes } from '@/libs/api'; -import { RouterPath } from '@/routes/path'; import { breakpoints } from '@/styles/variants'; import type { ThemeData } from '@/types'; export const ThemeHeroSection = () => { const { themeKey = '' } = useParams<{ themeKey: string }>(); - const [theme, setTheme] = useState(null); - const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - useEffect(() => { - const fetchTheme = async () => { - setLoading(true); - setError(''); - try { - const themes = await getThemes(); - const currentTheme = themes.themes.find((label: ThemeData) => label.key === themeKey); - - if (!currentTheme) { - setError('theme을 찾을 수 없음'); - setLoading(false); - return; - } - - if (typeof currentTheme === 'string') { - setError(currentTheme); - } else { - setTheme(currentTheme); - } - } catch (err) { - setError('데이터를 가져오는 중 오류가 발생했습니다.'); - } finally { - setLoading(false); + const { + data: themesData, + isLoading, + isError, + } = useQuery(['themes'], getThemes, { + onSuccess: (data) => { + const currentTheme = data.themes.find((theme: ThemeData) => theme.key === themeKey); + + if (!currentTheme) { + setError('Theme을 찾을 수 없습니다.'); } - }; + }, + onError: (err) => { + setError((err as Error).message); + }, + }); - fetchTheme(); - }, [themeKey]); + if (isLoading) { + return ( + + + + ); + } - if (loading) { - return ; + if (isError || error) { + return ( + + + + ); } - if (error) { + if (!themesData || !themesData.themes) { return ( - + ); } - if (!theme) { - return ; + const currentTheme = themesData.themes.find((theme: ThemeData) => theme.key === themeKey); + + if (!currentTheme) { + return ( + + + + ); } + return ( - + - {loading ? ( - - - - ) : error != '' ? ( - - - - ) : ( - - - {theme.title} - {theme.description && {theme.description}} - - )} + + {currentTheme.title} + {currentTheme.description && {currentTheme.description}} ); From 1b57606da8003cbd1129d8f1ae3f78810f54e1d4 Mon Sep 17 00:00:00 2001 From: Jungyoon Moon Date: Fri, 12 Jul 2024 15:38:59 +0900 Subject: [PATCH 16/17] docs: create questions --- README.md | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 15d5b027..ac57532b 100644 --- a/README.md +++ b/README.md @@ -2,31 +2,41 @@ [🔗 link](https://edu.nextstep.camp/s/hazAC9xa/ls/WAz8qraH) ----- +--- + ## Week 3 ### Step 1 -- [ ] 첨부된 oas.yaml 파일을 토대로 Request, Response Type 정의 -- [ ] React Query를 사용하지 말고 axios를 사용해서 구현 -- [ ] 첨부된 oas.yaml 파일과 목 API URL을 사용하여 API 구현 - - 메인 페이지 - Theme 카테고리 섹션 - - [ ] /api/v1/themes API를 사용하여 Section 구현 - - [ ] API는 Axios또는 React Query 등을 모두 활용해서 구현해도 ok - - 메인 페이지 - 실시간 급상승 선물랭킹 섹션 - - [ ] /api/v1/ranking/products API를 사용하여 Section 구현 (Axios 사용 가능) - - [ ] 필터 조건을 선택 하면 해당 조건에 맞게 API를 요청하여 보여지게 하기 - - Theme 페이지 - header - - [ ] url의 pathParams와 /api/v1/themes API를 사용하여 Section 구현 - - [ ] themeKey가 잘못 된 경우 메인 페이지로 연결 - - Theme 페이지 - 상품 목록 섹션 - - [ ] /api/v1/themes/{themeKey}/products API를 사용하여 상품 목록 구현 - - [ ] API 요청 시 한번에 20개의 상품 목록이 내려오도록 하기 + +- [ ] 첨부된 oas.yaml 파일을 토대로 Request, Response Type 정의 +- [ ] React Query를 사용하지 말고 axios를 사용해서 구현 +- [ ] 첨부된 oas.yaml 파일과 목 API URL을 사용하여 API 구현 +- 메인 페이지 - Theme 카테고리 섹션 + - [ ] /api/v1/themes API를 사용하여 Section 구현 + - [ ] API는 Axios또는 React Query 등을 모두 활용해서 구현해도 ok +- 메인 페이지 - 실시간 급상승 선물랭킹 섹션 + - [ ] /api/v1/ranking/products API를 사용하여 Section 구현 (Axios 사용 가능) + - [ ] 필터 조건을 선택 하면 해당 조건에 맞게 API를 요청하여 보여지게 하기 +- Theme 페이지 - header + - [ ] url의 pathParams와 /api/v1/themes API를 사용하여 Section 구현 + - [ ] themeKey가 잘못 된 경우 메인 페이지로 연결 +- Theme 페이지 - 상품 목록 섹션 + - [ ] /api/v1/themes/{themeKey}/products API를 사용하여 상품 목록 구현 + - [ ] API 요청 시 한번에 20개의 상품 목록이 내려오도록 하기 ### Step 2 + - [ ] 각 API에서 Loading 상태에 대한 UI 대응 하기 - [ ] 데이터가 없는 경우에 대한 UI 대응 하기 - [ ] Http Status에 따라 Error를 다르게 처리하기 ### Step 3 + 스크롤을 내리면 추가로 데이터를 요청하여 보여지게 하기 1단계에서 구현한 API를 react-query를 사용해서 구현하기 + +### Step4 + +- 질문 1. CORS 에러는 무엇이고 언제 발생하는지 설명해주세요. 이를 해결할 수 있는 방법에 대해서도 설명해주세요. +- 질문 2. 비동기 처리 방법인 callback, promise, async await에 대해 각각 장단점과 함께 설명해주세요. +- 질문 3. react query의 주요 특징에 대해 설명하고, queryKey는 어떤 역할을 하는지 설명해주세요. From 9003e1b9a8ac50e72141c8964a18afd650fa3c18 Mon Sep 17 00:00:00 2001 From: Jungyoon Moon <102630375+nnoonjy@users.noreply.github.com> Date: Fri, 12 Jul 2024 15:45:11 +0900 Subject: [PATCH 17/17] docs: update README.md --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ac57532b..e88f3044 100644 --- a/README.md +++ b/README.md @@ -8,27 +8,27 @@ ### Step 1 -- [ ] 첨부된 oas.yaml 파일을 토대로 Request, Response Type 정의 -- [ ] React Query를 사용하지 말고 axios를 사용해서 구현 -- [ ] 첨부된 oas.yaml 파일과 목 API URL을 사용하여 API 구현 +- [x] 첨부된 oas.yaml 파일을 토대로 Request, Response Type 정의 +- [x] React Query를 사용하지 말고 axios를 사용해서 구현 +- [x] 첨부된 oas.yaml 파일과 목 API URL을 사용하여 API 구현 - 메인 페이지 - Theme 카테고리 섹션 - - [ ] /api/v1/themes API를 사용하여 Section 구현 - - [ ] API는 Axios또는 React Query 등을 모두 활용해서 구현해도 ok + - [x] /api/v1/themes API를 사용하여 Section 구현 + - [x] API는 Axios또는 React Query 등을 모두 활용해서 구현해도 ok - 메인 페이지 - 실시간 급상승 선물랭킹 섹션 - - [ ] /api/v1/ranking/products API를 사용하여 Section 구현 (Axios 사용 가능) - - [ ] 필터 조건을 선택 하면 해당 조건에 맞게 API를 요청하여 보여지게 하기 + - [x] /api/v1/ranking/products API를 사용하여 Section 구현 (Axios 사용 가능) + - [x] 필터 조건을 선택 하면 해당 조건에 맞게 API를 요청하여 보여지게 하기 - Theme 페이지 - header - - [ ] url의 pathParams와 /api/v1/themes API를 사용하여 Section 구현 - - [ ] themeKey가 잘못 된 경우 메인 페이지로 연결 + - [x] url의 pathParams와 /api/v1/themes API를 사용하여 Section 구현 + - [x] themeKey가 잘못 된 경우 메인 페이지로 연결 - Theme 페이지 - 상품 목록 섹션 - - [ ] /api/v1/themes/{themeKey}/products API를 사용하여 상품 목록 구현 - - [ ] API 요청 시 한번에 20개의 상품 목록이 내려오도록 하기 + - [x] /api/v1/themes/{themeKey}/products API를 사용하여 상품 목록 구현 + - [x] API 요청 시 한번에 20개의 상품 목록이 내려오도록 하기 ### Step 2 -- [ ] 각 API에서 Loading 상태에 대한 UI 대응 하기 -- [ ] 데이터가 없는 경우에 대한 UI 대응 하기 -- [ ] Http Status에 따라 Error를 다르게 처리하기 +- [x] 각 API에서 Loading 상태에 대한 UI 대응 하기 +- [x] 데이터가 없는 경우에 대한 UI 대응 하기 +- [x] Http Status에 따라 Error를 다르게 처리하기 ### Step 3 @@ -38,5 +38,41 @@ ### Step4 - 질문 1. CORS 에러는 무엇이고 언제 발생하는지 설명해주세요. 이를 해결할 수 있는 방법에 대해서도 설명해주세요. + - CORS (Cross-Origin Resource Sharing): 웹 애플리케이션이 다른 출처(origin)에서 리소스를 요청할 때 발생할 수 있는 보안 메커니즘 + - 웹 브라우저는 기본적으로 스크립트에서 로드된 리소스가 해당 스크립트와 동일한 출처에서 온 것인지를 확인하며, 출처가 다를 경우 보안 상의 이유로 리소스 접근을 차단할 수 있음 + - CORS 에러 발생 조건 + - 다른 도메인, 프로토콜, 포트에서 리소스를 요청할 때 + - 서버 측에서 CORS 정책을 설정하지 않은 경우 + - 해결 방법 + 1. 서버 측 CORS 설정 + - 서버에서는 CORS를 허용하도록 설정해야 함. 이는 서버의 HTTP 응답 헤더에 Access-Control-Allow-Origin 등의 CORS 관련 헤더를 포함하여 클라이언트 요청을 허용하는 방법 + - 예를 들어, 모든 origin에서의 요청을 허용하려면 Access-Control-Allow-Origin: * 헤더를 설정할 수 있음 + 2. 프록시 서버 사용 + - 클라이언트와 서버 사이에 중개 역할을 하는 프록시 서버를 사용하여 CORS 정책을 우회할 수 있음 + - 예를 들어, 클라이언트에서 요청을 보내는 대신 프록시 서버에 요청을 보내고, 프록시 서버가 해당 요청을 대신 서버에 보내는 방식 + 3. CORS 브라우저 확장 프로그램 사용 + - 개발 중인 경우, CORS 문제를 우회하기 위해 브라우저 확장 프로그램을 사용할 수 있음 + - 예를 들어, Chrome의 "Allow CORS: Access-Control-Allow-Origin" 등의 확장 프로그램을 사용할 수 있음 + 4. 서버 측 API 호출 방법 변경 + - 서버에서 제공하는 API가 CORS를 허용하지 않는 경우, 서버 측 API 호출 방법을 변경하여 CORS 정책에 따라 요청을 보내야 할 수 있음 - 질문 2. 비동기 처리 방법인 callback, promise, async await에 대해 각각 장단점과 함께 설명해주세요. + - Callback + - 장점: 구현이 간단하고, 비동기 처리를 위한 기본적인 방법으로 사용 가능. + - 단점: 콜백 헬(callback hell)이 발생할 수 있으며, 에러 처리가 번거로울 수 있고 가독성이 떨어질 수 있음. + - Promise + - 장점: 콜백 헬을 해결하고, 비동기 작업의 순차적 흐름을 보장함. + - 단점: Promise 체이닝으로 인해 중첩된 로직이 복잡해질 수 있고, 예외 처리에 신경을 써야 함. + - Async/Await: + - 장점: 비동기 코드를 동기식으로 작성할 수 있어 가독성이 좋고, 에러 처리가 쉬움. + - 단점: Promise를 기반으로 하기 때문에 Promise의 단점을 일부 상속받을 수 있음. 또한, 순차적인 실행을 보장하기 위해 추가적인 코드가 필요할 수 있음. - 질문 3. react query의 주요 특징에 대해 설명하고, queryKey는 어떤 역할을 하는지 설명해주세요. + - React Query의 주요 특징 + - 간편한 데이터 관리: 서버 데이터를 가져오고 캐시하며, 상태 관리를 자동으로 처리하는 데 중점을 둠 + - 자동 캐싱 및 리프레시 관리: 데이터 요청의 캐시를 자동으로 관리하고, 필요할 때 새로고침(refetch) 기능을 제공하여 최신 데이터를 유지 + - 인터셉터 및 갱신 정책: HTTP 요청의 인터셉터를 사용하여 데이터 변형 및 추가 로직을 구현할 수 있으며, 갱신 정책을 통해 데이터 캐싱 및 리프레시 동작을 세밀하게 조정할 수 있음 + - 타입스크립트 지원: 타입스크립트와의 완벽한 통합을 지원하여 타입 안정성을 보장 + - queryKey의 역할 + - queryKey는 React Query에서 각 쿼리를 고유하게 식별하는 데 사용되는 키 + - 배열 형태로 제공되며, 배열 요소들은 쿼리의 종류와 파라미터를 나타냄 + - 예를 들어, useQuery(['userData', userId], fetchUserData)에서 ['userData', userId]는 fetchUserData 함수가 호출될 때 마다 고유한 쿼리로 사용됨 + - queryKey를 기반으로 React Query는 각 쿼리의 데이터를 캐싱하고 관리하며, 같은 queryKey로 요청이 중복되면 캐시된 데이터를 반환