diff --git a/.gitignore b/.gitignore index 4d29575de..532eddc40 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +.env \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3caba154f..caa450fa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,16 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.9.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "axios": "^1.7.7", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.26.2", "react-scripts": "5.0.1", + "typescript": "^5.6.3", "web-vitals": "^2.1.4" } }, @@ -4361,9 +4366,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.13", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz", - "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==", + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "license": "MIT", "dependencies": { "expect": "^29.0.0", @@ -4421,12 +4426,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.7.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", - "integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==", + "version": "22.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.19.8" } }, "node_modules/@types/node-forge": { @@ -4475,9 +4480,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.11", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", - "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -4485,9 +4490,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", "license": "MIT", "dependencies": { "@types/react": "*" @@ -18014,9 +18019,9 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "license": "Apache-2.0", "peer": true, "bin": { @@ -18024,7 +18029,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/unbox-primitive": { diff --git a/package.json b/package.json index cdb1393c7..5237e30d6 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,16 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.9.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "axios": "^1.7.7", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.26.2", "react-scripts": "5.0.1", + "typescript": "^5.6.3", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 000000000..828a458dc --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,23 @@ +import Items from "./pages/Items"; +import AddItem from "./pages/AddItem"; +import ItemDetailForm from "./components/ItemDetailForm"; +import Layout from "./layout"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; + +function App() { + return ( + + + }> + } /> + } /> + } /> + } /> + 페이지를 찾을 수 없습니다.} /> + + + + ); +} + +export default App; diff --git a/src/api.tsx b/src/api.tsx new file mode 100644 index 000000000..123c1a047 --- /dev/null +++ b/src/api.tsx @@ -0,0 +1,81 @@ +import instance from "./axiosInstance"; +import { + GetProductCommentsParams, + GetProductsParams, + GetProductsResponse, + ProductDetail, + GetCommentsResponse, +} from "./types"; + +/** + * 상품 목록을 가져옵니다. + * @param {Object} params - 상품 목록을 필터링할 파라미터 객체 + * @param {number} [params.page=1] - 요청할 페이지 번호 + * @param {number} [params.pageSize=10] - 한 페이지에 표시할 상품 개수 + * @param {string|null} [params.keyword=null] - 검색 키워드 + * @returns {Promise} 상품 목록 데이터를 반환합니다. + * @throws {Error} 정보 불러오기 실패 시 에러를 발생시킵니다. + */ +export async function getProducts( + params: GetProductsParams = {} +): Promise { + try { + const { data } = await instance.get("/products", { + params, + }); + return data; + } catch (error) { + throw new Error("정보를 불러오는데 실패했습니다."); + } +} + +/** + * 특정 상품의 상세 정보를 가져옵니다. + * @param {string} productId - 상품의 ID + * @returns {Promise} 상품 상세 정보를 반환합니다. + * @throws {Error} 상품 상세 정보 불러오기 실패 시 에러를 발생시킵니다. + */ +export async function getProductDetail( + productId: string +): Promise { + try { + const { data } = await instance.get( + `/products/${productId}` + ); + return data; + } catch (error) { + throw new Error("상품 상세 정보를 불러오는데 실패했습니다."); + } +} + +/** + * 특정 상품의 댓글 목록을 가져옵니다. + * @param {string} productId - 상품의 ID + * @param {number} [limit=10] - 가져올 댓글의 개수 (기본값: 10) + * @param {string|null} [cursor=null] - 페이지네이션을 위한 커서 (기본값: null) + * @returns {Promise} 댓글 데이터를 반환합니다. + * @throws {Error} 댓글 정보 불러오기 실패 시 에러를 발생시킵니다. + */ +export async function getProductComments( + productId: string, + limit: number = 9999, + cursor: string | null = null +): Promise { + try { + const params: GetProductCommentsParams = { limit }; + + if (cursor) { + params.cursor = cursor; + } + + const { data } = await instance.get( + `/products/${productId}/comments`, + { + params, + } + ); + return data; + } catch (error) { + throw new Error("댓글 정보를 불러오는데 실패했습니다."); + } +} diff --git a/src/axiosInstance.tsx b/src/axiosInstance.tsx new file mode 100644 index 000000000..60270e177 --- /dev/null +++ b/src/axiosInstance.tsx @@ -0,0 +1,12 @@ +import axios from "axios"; + +const BASE_URL = process.env.REACT_APP_BASE_URL; + +const instance = axios.create({ + baseURL: BASE_URL, + headers: { + "Content-Type": "application/json", + }, +}); + +export default instance; diff --git a/src/components/AddItemForm.tsx b/src/components/AddItemForm.tsx new file mode 100644 index 000000000..48796e767 --- /dev/null +++ b/src/components/AddItemForm.tsx @@ -0,0 +1,199 @@ +import { useState } from "react"; +import FileInput from "./FileInput"; +import useAsync from "../hooks/useAsync"; +import "./AddItemForm.css"; +import resetImg from "../assets/ic_X.svg"; +import { useNavigate } from "react-router-dom"; + +interface AddItemFormProps { + className?: string; + initialValues?: { + name: string; + favorite: number; + content: string; + price: string; + imgFile: File | null; + tags: string[]; + }; + initialPreview?: string; + onSubmit: (formData: FormData) => Promise<{ review: any } | null>; + onSubmitSuccess: (review: any) => void; +} + +const INITIAL_VALUE = { + name: "", + favorite: 0, + content: "", + price: "", + imgFile: null, + tags: [], +}; + +function AddItemForm({ + className = "", + initialValues = INITIAL_VALUE, + initialPreview, + onSubmit, + onSubmitSuccess, +}: AddItemFormProps) { + const navigate = useNavigate(); + const [values, setValues] = useState(initialValues); + const [isSubmitting, submittingError, onSubmitAsync] = useAsync(onSubmit) as [ + boolean, + Error | null, + (formData: FormData) => Promise<{ review: any } | null> + ]; + const [tagInput, setTagInput] = useState(""); + + // 유효성 검사 + const isValidForm = + values.name && values.content && values.price && values.tags.length > 0; + + const handleChange = (name: string, value: any) => { + setValues((prevValues) => ({ + ...prevValues, + [name]: value, + })); + }; + + const handleInputChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + handleChange(name, value); + }; + + const handleFileChange = (name: string, file: File | null) => { + handleChange(name, file); + }; + + const handleTagInputChange = (e: React.ChangeEvent) => { + setTagInput(e.target.value); + }; + + const handleTagAdd = () => { + if (tagInput.trim() && !values.tags.includes(tagInput.trim())) { + handleChange("tags", [...values.tags, tagInput.trim()]); + setTagInput(""); + } + }; + + const handleTagKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleTagAdd(); + } + }; + + const handleTagRemove = (tagToRemove: string) => { + handleChange( + "tags", + values.tags.filter((tag) => tag !== tagToRemove) + ); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(); + formData.append("name", values.name); + formData.append("favorite", values.favorite.toString()); + formData.append("content", values.content); + formData.append("price", values.price); + if (values.imgFile) formData.append("imgFile", values.imgFile); + formData.append("tags", JSON.stringify(values.tags)); + + const result = await onSubmitAsync(formData); + if (!result) return; + + const { review } = result; + setValues(INITIAL_VALUE); + onSubmitSuccess(review); + + navigate("/items"); + }; + + return ( +
+
+
상품 등록하기
+ +
+
+
+
상품 이미지
+ +
+
+
상품명
+ +
+
+
상품 소개
+