diff --git a/craco.config.js b/craco.config.js index e39f09a..f3fab18 100644 --- a/craco.config.js +++ b/craco.config.js @@ -4,12 +4,15 @@ module.exports = { webpack: { alias: { "@": path.resolve(__dirname, "src"), + "@contexts": path.resolve(__dirname, "src/contexts"), "@apis": path.resolve(__dirname, "src/apis"), "@components": path.resolve(__dirname, "src/components"), "@hooks": path.resolve(__dirname, "src/hooks"), "@pages": path.resolve(__dirname, "src/pages"), "@routes": path.resolve(__dirname, "src/routes"), "@utils": path.resolve(__dirname, "src/utils"), + "@icons": path.resolve(__dirname, "src/icons"), + "@styles": path.resolve(__dirname, "src/styles"), }, }, }; diff --git a/package-lock.json b/package-lock.json index f1c2e1b..a4eef19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "axios": "^1.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-helmet-async": "^1.3.0", "react-router-dom": "^6.4.2", "react-scripts": "5.0.1", "styled-components": "^5.3.6", @@ -8901,6 +8902,14 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/ipaddr.js": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", @@ -14013,6 +14022,27 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "node_modules/react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, + "node_modules/react-helmet-async": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz", + "integrity": "sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.2.0", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -23506,6 +23536,14 @@ "side-channel": "^1.0.4" } }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "ipaddr.js": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", @@ -27153,6 +27191,23 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, + "react-helmet-async": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz", + "integrity": "sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==", + "requires": { + "@babel/runtime": "^7.12.5", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.2.0", + "shallowequal": "^1.1.0" + } + }, "react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/package.json b/package.json index 513f9cb..0bf9e34 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "axios": "^1.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-helmet-async": "^1.3.0", "react-router-dom": "^6.4.2", "react-scripts": "5.0.1", "styled-components": "^5.3.6", diff --git a/src/App.js b/src/App.js index 491dfe5..0ea0594 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,19 @@ +import Router from "@routes/Router"; +import { CarListContextProvider } from "@contexts/CarListContext"; +import { BrowserRouter } from "../node_modules/react-router-dom/dist/index"; +import { theme } from "@styles/theme"; +import { ThemeProvider } from "styled-components"; + function App() { - return

Hello React!

; + return ( + + + + + + + + ); } export default App; diff --git a/src/apis/instance.js b/src/apis/instance.js new file mode 100644 index 0000000..77c7f12 --- /dev/null +++ b/src/apis/instance.js @@ -0,0 +1,7 @@ +import axios from "../../node_modules/axios/index"; + +const instance = axios.create({ + baseURL: process.env.REACT_APP_API_URL, +}); + +export default instance; diff --git a/src/components/CarItem.jsx b/src/components/CarItem.jsx new file mode 100644 index 0000000..f07d4ce --- /dev/null +++ b/src/components/CarItem.jsx @@ -0,0 +1,134 @@ +import React from "react"; + +import { convertDate } from "@utils/date"; +import { fuelEnum, segmentEnum } from "@utils/enum"; + +import { GSInfoText } from "@styles/styled"; +import styled from "styled-components"; + +const CarItem = ({ carItem }) => { + const { car, isLoading, isError } = carItem; + + return ( + <> + {isLoading && 불러오는중} + {isError && 에러가 발생했습니다} + {car && ( + <> + + carItem + + + + {car.attribute.brand} + {car.attribute.name} + 월 {car.amount.toLocaleString("ko-KR")}원 + + + 차량정보 + + 차종 + {segmentEnum[car.attribute.segment]} + + + 연료 + {fuelEnum[car.attribute.fuelType]} + + + 이용 가능일 + {convertDate(car.startDate)} + + + {car.insurance.length !== 0 && ( + <> + 보험 + {car.insurance.map((item) => ( + + {item.name} + {item.description} + + ))} + + )} + + {car.additionalProducts.length !== 0 && ( + <> + 추가상품 + {car.additionalProducts.map((item) => ( + + {item.name} + 월 {item.amount.toLocaleString("ko-KR")}원 + + ))} + + )} + + )} + + ); +}; + +const SCarImage = styled.div` + display: flex; + justify-content: center; + align-items: center; + + height: 205px; + + img { + width: 100%; + } +`; + +const SCarInfo = styled.div` + display: flex; + flex-direction: column; + padding: 0 20px; + + > span:nth-child(1) { + font-size: 20px; + font-weight: 700; + } + + > span:nth-child(2) { + font-size: 24px; + font-weight: 700; + } + + > span:nth-child(3) { + height: 48px; + margin-top: 21px; + text-align: end; + } +`; + +const SSubTitle = styled.div` + display: flex; + align-items: center; + + width: 100%; + height: 48px; + padding: 0 20px; + background-color: ${({ theme }) => theme.color.blue}; + + font-size: 17px; + font-weight: 700; + color: ${({ theme }) => theme.color.white}; +`; + +const SContent = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + height: 48px; + padding: 0 20px; + + font-size: 17px; + + > span:nth-child(1) { + font-weight: 700; + } +`; + +export default CarItem; diff --git a/src/components/CarList.jsx b/src/components/CarList.jsx new file mode 100644 index 0000000..29a78b6 --- /dev/null +++ b/src/components/CarList.jsx @@ -0,0 +1,92 @@ +import React from "react"; +import { useNavigate } from "../../node_modules/react-router-dom/dist/index"; +import { fuelEnum, segmentEnum } from "@/utils/enum"; +import { GSInfoText } from "@styles/styled"; +import styled from "styled-components"; + +const CarList = ({ carList }) => { + const navigate = useNavigate(); + const { cars, isLoading, isError } = carList; + + const handleClickCarItem = (id) => { + navigate(`/detail/${id}`); + }; + + return ( + + {isLoading && 불러오는중} + {isError && 에러가 발생했습니다} + {cars?.length === 0 && 차량이 없습니다.} + + {cars?.map((car) => ( + handleClickCarItem(car.id)}> +
+ + {car.attribute.brand} + {car.attribute.name} + + + + {segmentEnum[car.attribute.segment]} / {fuelEnum[car.attribute.fuelType]} + + 월 {car.amount.toLocaleString("ko-KR")}원 부터 + +
+ +
+ {car.attribute.name} +
+
+ ))} +
+ ); +}; + +const SCarListContainer = styled.main` + display: flex; + flex-direction: column; +`; + +const SCarItem = styled.div` + display: flex; + justify-content: space-between; + + height: 120px; + padding: 20px; + border-bottom: 1px solid ${({ theme }) => theme.color.black}; + + cursor: pointer; + + > div:nth-child(1) { + display: flex; + flex-direction: column; + justify-content: space-between; + } + + > div:nth-child(2) { + width: 152px; + + img { + width: 100%; + } + } +`; + +const SCarName = styled.div` + display: flex; + flex-direction: column; + + font-size: 14px; + font-weight: 700; + line-height: 17px; +`; + +const SCarInfo = styled.div` + display: flex; + flex-direction: column; + + font-size: 12px; + line-height: 15px; +`; + +export default CarList; diff --git a/src/components/Header.jsx b/src/components/Header.jsx new file mode 100644 index 0000000..aaf69e4 --- /dev/null +++ b/src/components/Header.jsx @@ -0,0 +1,41 @@ +import React from "react"; +import { useNavigate } from "../../node_modules/react-router-dom/dist/index"; +import IconBack from "@icons/ICON_Back.svg"; +import styled from "styled-components"; + +const Header = ({ children, isBack }) => { + const navigate = useNavigate(); + + return ( + + {isBack && IconBack navigate(-1)} />} + {children} + + ); +}; + +const SHeader = styled.header` + position: relative; + + display: flex; + justify-content: center; + align-items: center; + + width: 100%; + height: 60px; + border-bottom: 1px solid ${({ theme }) => theme.color.black}; + + img { + position: absolute; + left: 25px; + + cursor: pointer; + } + + span { + font-size: 17px; + font-weight: 700; + } +`; + +export default Header; diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx new file mode 100644 index 0000000..10a159f --- /dev/null +++ b/src/components/Layout.jsx @@ -0,0 +1,22 @@ +import React from "react"; +import styled from "styled-components"; + +const Layout = ({ children }) => { + return {children}; +}; + +const SLayout = styled.div` + @media screen and (min-width: 450px) { + width: 450px; + } + + position: absolute; + left: 50%; + transform: translateX(-50%); + + min-width: 360px; + width: 100%; + height: 100%; +`; + +export default Layout; diff --git a/src/components/Nav.jsx b/src/components/Nav.jsx new file mode 100644 index 0000000..678ecbe --- /dev/null +++ b/src/components/Nav.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import { segmentArr, fuelArr } from "@utils/enum"; +import styled from "styled-components"; + +const Nav = ({ handleClickCategory }) => { + return ( + + + + + + ); +}; + +const SNav = styled.nav` + display: flex; + flex-direction: column; + + border-bottom: 1px solid ${({ theme }) => theme.color.black}; + + ul { + display: flex; + gap: 8px; + padding: 6px 12px; + } +`; + +const SButton = styled.li` + display: flex; + justify-content: center; + align-items: center; + + padding: 5px 18px; + background-color: ${({ theme }) => theme.color.gray}; + + border-radius: 999px; + + font-size: 14px; + font-weight: 700; + + cursor: pointer; +`; + +export default Nav; diff --git a/src/contexts/CarItemContext.js b/src/contexts/CarItemContext.js new file mode 100644 index 0000000..a72be30 --- /dev/null +++ b/src/contexts/CarItemContext.js @@ -0,0 +1,36 @@ +import { createContext, useCallback, useContext, useState } from "react"; +import instance from "@apis/instance"; + +const CarItemContext = createContext(null); + +export const CarItemContextProvider = ({ children }) => { + const [carItem, setCarItem] = useState({ + car: null, + isLoading: true, + isError: false, + }); + + const handleGetCarItem = useCallback(async (id) => { + try { + const { data } = await instance.get("/cars"); + const [carItem] = data.payload.filter((item) => item.id === +id); + setCarItem((prev) => ({ ...prev, car: carItem, isLoading: false })); + } catch (error) { + setCarItem((prev) => ({ ...prev, isLoading: false, isError: true })); + } + }, []); + + return ( + + {children} + + ); +}; + +export const useCarItemState = () => { + const state = useContext(CarItemContext); + if (!state) { + throw new Error("프로바이더를 찾을 수 없습니다."); + } + return state; +}; diff --git a/src/contexts/CarListContext.js b/src/contexts/CarListContext.js new file mode 100644 index 0000000..26867c7 --- /dev/null +++ b/src/contexts/CarListContext.js @@ -0,0 +1,68 @@ +import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import instance from "@apis/instance"; +import { getKeyByValue } from "@utils/function"; +import { fuelEnum, segmentEnum } from "@utils/enum"; + +const CarListContext = createContext(null); + +export const CarListContextProvider = ({ children }) => { + const [carList, setCarList] = useState({ + cars: null, + isLoading: true, + isError: false, + }); + + const [category, setCategory] = useState({ + segment: null, + fuelType: null, + }); + + const handleGetCarList = useCallback(async () => { + try { + let data; + if (!category.segment && !category.fuelType) { + data = await instance.get("/cars"); + } else { + const query = Object.keys(category) + .map((item) => category[item] && `${item}=${category[item]}`) + .join("&"); + data = await instance.get(`/cars?${query}`); + } + setCarList((prev) => ({ ...prev, cars: data.data.payload, isLoading: false })); + } catch (error) { + setCarList((prev) => ({ ...prev, isLoading: false, isError: true })); + } + }, [category]); + + useEffect(() => { + handleGetCarList(); + }, [handleGetCarList]); + + const handleClickCategory = (event) => { + const name = event.target.getAttribute("name"); + const value = event.target.getAttribute("value"); + + let newValue; + if (name === "segment") { + newValue = getKeyByValue(segmentEnum, value); + } else { + newValue = getKeyByValue(fuelEnum, value); + } + + setCategory((prev) => ({ ...prev, [name]: newValue })); + }; + + return ( + + {children} + + ); +}; + +export const useCarListState = () => { + const state = useContext(CarListContext); + if (!state) { + throw new Error("프로바이더를 찾을 수 없습니다."); + } + return state; +}; diff --git a/src/icons/ICON_Back.svg b/src/icons/ICON_Back.svg new file mode 100644 index 0000000..62d1f16 --- /dev/null +++ b/src/icons/ICON_Back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/index.css b/src/index.css index 714ab0e..427c7e8 100644 --- a/src/index.css +++ b/src/index.css @@ -1,11 +1,22 @@ +* { + box-sizing: border-box; +} + body { margin: 0; + padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +ul { + list-style: none; + margin: 0; + padding: 0; +} + code { font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; } diff --git a/src/pages/Detail.jsx b/src/pages/Detail.jsx new file mode 100644 index 0000000..60072f2 --- /dev/null +++ b/src/pages/Detail.jsx @@ -0,0 +1,27 @@ +import React, { useEffect } from "react"; +import { useParams } from "../../node_modules/react-router-dom/dist/index"; +import Header from "@components/Header"; +import Layout from "@components/Layout"; + +import { useCarItemState } from "@contexts/CarItemContext"; + +import CarItem from "../components/CarItem"; + +const Detail = () => { + const { id } = useParams(); + const { carItem, handleGetCarItem } = useCarItemState(); + + useEffect(() => { + handleGetCarItem(id); + }, [id, handleGetCarItem]); + + return ( + +
차량상세
+ + +
+ ); +}; + +export default Detail; diff --git a/src/pages/Main.jsx b/src/pages/Main.jsx new file mode 100644 index 0000000..bd2dfb7 --- /dev/null +++ b/src/pages/Main.jsx @@ -0,0 +1,23 @@ +import React from "react"; +import { useCarListState } from "@contexts/CarListContext"; + +import Header from "@components/Header"; +import CarList from "@components/CarList"; +import Layout from "@components/Layout"; +import Nav from "@components/Nav"; + +const Main = () => { + const { carList, handleClickCategory } = useCarListState(); + + return ( + +
전체차량
+ +