diff --git a/package-lock.json b/package-lock.json index 58accf843..1b069c48a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,15 @@ "@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.10.5", + "@types/react": "^19.0.3", + "@types/react-dom": "^19.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.28.0", "react-scripts": "5.0.1", + "typescript": "^5.7.2", "web-vitals": "^2.1.4" } }, @@ -3784,6 +3789,26 @@ "node": ">=12" } }, + "node_modules/@testing-library/react/node_modules/@types/react": { + "version": "18.3.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", + "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@testing-library/react/node_modules/@types/react-dom": { + "version": "18.3.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", + "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "node_modules/@testing-library/react/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4056,9 +4081,10 @@ } }, "node_modules/@types/jest": { - "version": "29.5.4", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.4.tgz", - "integrity": "sha512-PhglGmhWeD46FYOVLt3X7TiWjzwuVGW9wG/4qocPevXMjCmrIc5b6db9WjeGE4QYVpUAWMDv3v0IiBwObY289A==", + "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", "pretty-format": "^29.0.0" @@ -4307,9 +4333,13 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, "node_modules/@types/node": { - "version": "20.5.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.9.tgz", - "integrity": "sha512-PcGNd//40kHAS3sTlzKB9C9XL4K0sTup8nbG5lC14kzEteTNuAFh9u5nA0o5TWnSG2r/JNPRXFVcHJIIeRlmqQ==" + "version": "22.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", + "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } }, "node_modules/@types/parse-json": { "version": "4.0.0", @@ -4322,9 +4352,11 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==" }, "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "license": "MIT", + "peer": true }, "node_modules/@types/q": { "version": "1.5.6", @@ -4342,21 +4374,21 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "node_modules/@types/react": { - "version": "18.2.21", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz", - "integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.3.tgz", + "integrity": "sha512-UavfHguIjnnuq9O67uXfgy/h3SRJbidAYvNjLceB+2RIKVRBzVsh0QO+Pw6BCSQqFS9xwzKfwstXx0m6AbAREA==", + "license": "MIT", "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", - "integrity": "sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==", - "dependencies": { - "@types/react": "*" + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz", + "integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" } }, "node_modules/@types/resolve": { @@ -4372,11 +4404,6 @@ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, - "node_modules/@types/scheduler": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" - }, "node_modules/@types/semver": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.1.tgz", @@ -16666,16 +16693,16 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true, + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/unbox-primitive": { @@ -16697,6 +16724,12 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", diff --git a/package.json b/package.json index 7b54ab4b6..b76fe328f 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,15 @@ "@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.10.5", + "@types/react": "^19.0.3", + "@types/react-dom": "^19.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.28.0", "react-scripts": "5.0.1", + "typescript": "^5.7.2", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/src/App.js b/src/App.tsx similarity index 65% rename from src/App.js rename to src/App.tsx index 74769b68d..3a9f82662 100644 --- a/src/App.js +++ b/src/App.tsx @@ -1,8 +1,9 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import Header from "./component/Navigation/Header"; import MainPage from "./pages/MainPage"; -import UsedMarketPage from "./pages/UsedMarketPage"; import PostProductPage from "./pages/PostProductPage"; +import ProductDetail from "./pages/ProductDetail"; +import "./styles/global.css"; function App() { return ( @@ -10,7 +11,10 @@ function App() {
} /> - } /> + + } /> + } /> + } /> diff --git a/src/api.js b/src/api.js deleted file mode 100644 index 4813b3b7e..000000000 --- a/src/api.js +++ /dev/null @@ -1,20 +0,0 @@ -export async function getProduct({ - page = 1, - pageSize = 4, - orderBy = "favorite", - keyword = "", -}) { - //기본 URL - const baseURL = "https://panda-market-api.vercel.app/products"; - //URL 객체 - const url = new URL(baseURL); - url.searchParams.set("page", page); - url.searchParams.set("pageSize", pageSize); - url.searchParams.set("orderBy", orderBy); - url.searchParams.set("keyword", keyword); - //API 호출 - console.log(url.toString()); - const response = await fetch(url.toString()); - const data = await response.json(); - return data; -} diff --git a/src/api.tsx b/src/api.tsx new file mode 100644 index 000000000..7e28b6094 --- /dev/null +++ b/src/api.tsx @@ -0,0 +1,50 @@ +interface Product { + page?: number; + pageSize: number; + orderBy: string; + keyword?: string; +} + +interface Comment { + productSlug: string; + limit: number; +} + +export async function getProduct({ + page = 1, + pageSize = 4, + orderBy = "favorite", + keyword = "", +}: Product) { + //기본 URL + const baseURL = "https://panda-market-api.vercel.app/products"; + //URL 객체 + const url = new URL(baseURL); + url.searchParams.set("page", page.toString()); + url.searchParams.set("pageSize", pageSize.toString()); + url.searchParams.set("orderBy", orderBy); + url.searchParams.set("keyword", keyword); + //API 호출 + console.log(url.toString()); + const response = await fetch(url.toString()); + const data = await response.json(); + return data; +} + +//제품 상세페이지 +export async function getProductDetail(productSlug: string) { + const response = await fetch( + `https://panda-market-api.vercel.app/products/${productSlug}` + ); + const data = await response.json(); + return data; +} + +//제품 코멘트 +export async function getProductComment({ productSlug, limit }: Comment) { + const response = await fetch( + `https://panda-market-api.vercel.app/products/${productSlug}/comments?limit=${limit}` + ); + const data = await response.json(); + return data; +} diff --git a/src/asset/Img_inquiry_empty.png b/src/asset/Img_inquiry_empty.png new file mode 100644 index 000000000..4c725d079 Binary files /dev/null and b/src/asset/Img_inquiry_empty.png differ diff --git a/src/asset/fdec874e30ff90436580b2b8d751e9f5.png b/src/asset/fdec874e30ff90436580b2b8d751e9f5.png new file mode 100644 index 000000000..a84b2160e Binary files /dev/null and b/src/asset/fdec874e30ff90436580b2b8d751e9f5.png differ diff --git a/src/asset/ic_back.png b/src/asset/ic_back.png new file mode 100644 index 000000000..d0fecd482 Binary files /dev/null and b/src/asset/ic_back.png differ diff --git a/src/asset/ic_kebab.png b/src/asset/ic_kebab.png new file mode 100644 index 000000000..14f12b84a Binary files /dev/null and b/src/asset/ic_kebab.png differ diff --git a/src/component/AllProduct/AllProductItem.js b/src/component/AllProduct/AllProductItem.js deleted file mode 100644 index 6f7c08735..000000000 --- a/src/component/AllProduct/AllProductItem.js +++ /dev/null @@ -1,36 +0,0 @@ -import noPhotoImg from "../../asset/nophoto.png"; -import HeartIcon from "../../asset/ic_heart.png"; -import "./AllProductItem.css"; - -function AllProductItem({ item }) { - const onErrorImg = (e) => { - e.target.src = noPhotoImg; - }; - //이미지 링크가 잘못된 링크일 때, 기본 이미지 출력 - return ( -
- {item.images[0] ? ( - 상품 이미지 - ) : ( - 상품 이미지 - )} -

{item.name}

-

{item.price.toLocaleString()}

-
- 찜 아이콘 - {item.favoriteCount} -
-
- ); -} - -export default AllProductItem; diff --git a/src/component/AllProduct/AllProductItem.css b/src/component/AllProduct/AllProductItem.module.css similarity index 57% rename from src/component/AllProduct/AllProductItem.css rename to src/component/AllProduct/AllProductItem.module.css index 7ca52287f..032c802b9 100644 --- a/src/component/AllProduct/AllProductItem.css +++ b/src/component/AllProduct/AllProductItem.module.css @@ -3,19 +3,26 @@ margin: 0; } -.allproduct-img { +.allproduct_img { width: 221px; height: 221px; border-radius: 16px; object-fit: cover; } -.allproduct-popularity { +.allproduct_popularity { width: 16px; height: 16px; + color: black; } -.allproduct-title { +.allproduct_title { font-size: 14px; font-weight: 500; + color: black; +} + +.link { + text-decoration: none; + color: black; } diff --git a/src/component/AllProduct/AllProductItem.tsx b/src/component/AllProduct/AllProductItem.tsx new file mode 100644 index 000000000..7ae4695b7 --- /dev/null +++ b/src/component/AllProduct/AllProductItem.tsx @@ -0,0 +1,57 @@ +import noPhotoImg from "../../asset/nophoto.png"; +import HeartIcon from "../../asset/ic_heart.png"; +import styles from "./AllProductItem.module.css"; +import { Link } from "react-router-dom"; +import { SyntheticEvent } from "react"; + +interface Product { + id: number; + name: string; + price: number; + description: string; + tags: string[]; + images: string[]; + createdAt: string; + ownerNickname: string; + favoriteCount: number; +} + +function AllProductItem({ item }: { item: Product }) { + const onErrorImg = (e: SyntheticEvent) => { + e.currentTarget.src = noPhotoImg; + }; + //이미지 링크가 잘못된 링크일 때, 기본 이미지 출력 + + return ( + +
+ {item.images[0] ? ( + 상품 이미지 + ) : ( + 상품 이미지 + )} +

{item.name}

+

{item.price.toLocaleString()}

+
+ 찜 아이콘 + {item.favoriteCount} +
+
+ + ); +} + +export default AllProductItem; diff --git a/src/component/AllProduct/AllProductList.css b/src/component/AllProduct/AllProductList.module.css similarity index 88% rename from src/component/AllProduct/AllProductList.css rename to src/component/AllProduct/AllProductList.module.css index 4d4972979..599266cc2 100644 --- a/src/component/AllProduct/AllProductList.css +++ b/src/component/AllProduct/AllProductList.module.css @@ -11,14 +11,14 @@ gap: 43px; } -.allsection-container { +.allsection_container { width: 1200px; display: flex; flex-direction: column; gap: 24px; } -.allproduct-list { +.allproduct_list { display: flex; flex-wrap: wrap; gap: 23px; diff --git a/src/component/AllProduct/AllProductList.js b/src/component/AllProduct/AllProductList.tsx similarity index 63% rename from src/component/AllProduct/AllProductList.js rename to src/component/AllProduct/AllProductList.tsx index 20ae89a5b..1e51eaa7d 100644 --- a/src/component/AllProduct/AllProductList.js +++ b/src/component/AllProduct/AllProductList.tsx @@ -1,19 +1,38 @@ import AllProductItem from "./AllProductItem"; -import "./AllProductList.css"; +import styles from "./AllProductList.module.css"; import { getProduct } from "../../api"; import { useEffect, useState } from "react"; import NavBar from "../Navigation/NavBar"; import PageButton from "../Pagination/PageButton"; +interface ProductValue { + page?: number; + pageSize: number; + orderBy: string; + keyword?: string; +} + +interface Product { + id: number; + name: string; + price: number; + description: string; + tags: string[]; + images: string[]; + createdAt: string; + ownerNickname: string; + favoriteCount: number; +} + function AllProductList() { - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [orderBy, setOrderBy] = useState("recent"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [keyword, setKeyword] = useState(""); const [maxPage, setMaxPage] = useState(5); - const handleLoad = async (value) => { + const handleLoad = async (value: ProductValue) => { const result = await getProduct(value); const { list, totalCount } = result; setItems(list); @@ -25,25 +44,25 @@ function AllProductList() { handleLoad({ page, pageSize, orderBy, keyword }); }, [page, pageSize, orderBy, keyword]); - const handleOrder = (value) => { + const handleOrder = (value: string) => { setOrderBy(value); setPage(1); }; - const handlePage = (value) => { + const handlePage = (value: number) => { setPage(value); }; - const handleKeyword = (value) => { + const handleKeyword = (value: string) => { setKeyword(value); setPage(1); }; return ( -
-
+
+
-
+
{items.map((item) => { return ; })} diff --git a/src/component/BestProduct/BestProductItem.js b/src/component/BestProduct/BestProductItem.js deleted file mode 100644 index 1fbcb796a..000000000 --- a/src/component/BestProduct/BestProductItem.js +++ /dev/null @@ -1,22 +0,0 @@ -import HeartIcon from "../../asset/ic_heart.png"; -import "./BestProductItem.css"; - -function ProductItem({ item }) { - return ( -
- 상품 이미지 -

{item.name}

-

{item.price.toLocaleString()}

-
- 찜 아이콘 - {item.favoriteCount} -
-
- ); -} - -export default ProductItem; diff --git a/src/component/BestProduct/BestProductItem.css b/src/component/BestProduct/BestProductItem.module.css similarity index 63% rename from src/component/BestProduct/BestProductItem.css rename to src/component/BestProduct/BestProductItem.module.css index 76b835023..90d242854 100644 --- a/src/component/BestProduct/BestProductItem.css +++ b/src/component/BestProduct/BestProductItem.module.css @@ -3,19 +3,24 @@ margin: 0; } -.bestproduct-img { +.bestproduct_img { width: 282px; height: 282px; border-radius: 16px; object-fit: cover; } -.bestproduct-title { +.bestproduct_title { font-size: 14px; font-weight: 500; } -.bestproduct-popularity { +.bestproduct_popularity { width: 16px; height: 16px; } + +.link { + text-decoration: none; + color: black; +} diff --git a/src/component/BestProduct/BestProductItem.tsx b/src/component/BestProduct/BestProductItem.tsx new file mode 100644 index 000000000..9441f89e8 --- /dev/null +++ b/src/component/BestProduct/BestProductItem.tsx @@ -0,0 +1,41 @@ +import { Link } from "react-router-dom"; +import HeartIcon from "../../asset/ic_heart.png"; +import styles from "./BestProductItem.module.css"; + +interface Product { + id: number; + name: string; + price: number; + description: string; + tags: string[]; + images: string[]; + createdAt: string; + ownerNickname: string; + favoriteCount: number; +} + +function ProductItem({ item }: { item: Product }) { + return ( + +
+ 상품 이미지 +

{item.name}

+

{item.price.toLocaleString()}

+
+ 찜 아이콘 + {item.favoriteCount} +
+
+ + ); +} + +export default ProductItem; diff --git a/src/component/BestProduct/BestProductList.js b/src/component/BestProduct/BestProductList.js deleted file mode 100644 index f01ca505d..000000000 --- a/src/component/BestProduct/BestProductList.js +++ /dev/null @@ -1,31 +0,0 @@ -import BestProductItem from "./BestProductItem"; -import "./BestProductList.css"; -import { getProduct } from "../../api"; -import { useEffect, useState } from "react"; - -function BestProductList() { - const [items, setItems] = useState([]); - const handleLoad = async (value) => { - let result = await getProduct(value); - const { list } = result; - setItems(list); - }; - useEffect(() => { - handleLoad({ pageSize: 4, orderBy: "favorite" }); - }, []); - - return ( -
-
-

베스트 상품

-
- {items.map((item) => { - return ; - })} -
-
-
- ); -} - -export default BestProductList; diff --git a/src/component/BestProduct/BestProductList.css b/src/component/BestProduct/BestProductList.module.css similarity index 81% rename from src/component/BestProduct/BestProductList.css rename to src/component/BestProduct/BestProductList.module.css index b4b4ef8a8..d15f9f27b 100644 --- a/src/component/BestProduct/BestProductList.css +++ b/src/component/BestProduct/BestProductList.module.css @@ -9,11 +9,11 @@ align-items: center; } -.bestsection-container { +.bestsection_container { width: 1200px; } -.bestproduct-list { +.bestproduct_list { display: flex; gap: 24px; flex-wrap: wrap; diff --git a/src/component/BestProduct/BestProductList.tsx b/src/component/BestProduct/BestProductList.tsx new file mode 100644 index 000000000..f6ec97d6f --- /dev/null +++ b/src/component/BestProduct/BestProductList.tsx @@ -0,0 +1,52 @@ +import BestProductItem from "./BestProductItem"; +import styles from "./BestProductList.module.css"; +import { getProduct } from "../../api"; +import { useEffect, useState } from "react"; + +interface ProductValue { + page?: number; + pageSize: number; + orderBy: string; + keyword?: string; +} + +interface Product { + id: number; + name: string; + price: number; + description: string; + tags: string[]; + images: string[]; + createdAt: string; + ownerNickname: string; + favoriteCount: number; +} + +function BestProductList() { + const [items, setItems] = useState([]); + + const handleLoad = async (value: ProductValue) => { + let result = await getProduct(value); + const { list } = result; + setItems(list); + }; + + useEffect(() => { + handleLoad({ pageSize: 4, orderBy: "favorite" }); + }, []); + + return ( +
+
+

베스트 상품

+
+ {items.map((item) => { + return ; + })} +
+
+
+ ); +} + +export default BestProductList; diff --git a/src/component/Navigation/Header.js b/src/component/Navigation/Header.js deleted file mode 100644 index 629c159e1..000000000 --- a/src/component/Navigation/Header.js +++ /dev/null @@ -1,28 +0,0 @@ -import Logo from "../../asset/panda.png"; -import UserImg from "../../asset/userIcon.png"; -import "./Header.css"; -import { Link } from "react-router-dom"; - -function Header() { - return ( -
-
- - 판다마켓 로고 - 판다마켓 - -
- - 자유게시판 - - - 중고마켓 - -
- 유저 이미지 -
-
- ); -} - -export default Header; diff --git a/src/component/Navigation/Header.css b/src/component/Navigation/Header.module.css similarity index 82% rename from src/component/Navigation/Header.css rename to src/component/Navigation/Header.module.css index 1cafd1d29..a8f722c1a 100644 --- a/src/component/Navigation/Header.css +++ b/src/component/Navigation/Header.module.css @@ -11,7 +11,7 @@ border-bottom: solid 1px #dfdfdf; } -.header-container { +.headercontainer { display: flex; align-items: center; width: 100%; @@ -26,30 +26,30 @@ text-decoration: none; } -.header__logo-img { +.header__logoimg { width: 40px; } -.header__logo-title { +.header__logotitle { font-size: 25px; font-weight: 700; color: #3692ff; } -.header__Link-container { +.header__Linkcontainer { display: flex; gap: 30px; flex-grow: 1; } -.header__Link-title { +.header__Linktitle { font-size: 18px; font-weight: 700; color: #4b5563; text-decoration: none; } -.header__profile-img { +.header__profileimg { width: 40px; height: 40px; } diff --git a/src/component/Navigation/Header.tsx b/src/component/Navigation/Header.tsx new file mode 100644 index 000000000..4389a8888 --- /dev/null +++ b/src/component/Navigation/Header.tsx @@ -0,0 +1,36 @@ +import Logo from "../../asset/panda.png"; +import UserImg from "../../asset/userIcon.png"; +import styles from "./Header.module.css"; +import { Link } from "react-router-dom"; + +function Header() { + return ( +
+
+ + 판다마켓 로고 + 판다마켓 + +
+ + 자유게시판 + + + 중고마켓 + +
+ 유저 이미지 +
+
+ ); +} + +export default Header; diff --git a/src/component/Navigation/NavBar.js b/src/component/Navigation/NavBar.js deleted file mode 100644 index 58002e4e7..000000000 --- a/src/component/Navigation/NavBar.js +++ /dev/null @@ -1,44 +0,0 @@ -import { Link } from "react-router-dom"; -import "./NavBar.css"; -import { useState } from "react"; - -function NavBar({ handleOrder, handleKeyword }) { - const [searchInput, setSearchInput] = useState(""); - - const onClickOption = (e) => { - handleOrder(e.target.value); - }; - - const handleChangeInput = (e) => { - setSearchInput(e.target.value); - }; - - //엔터키 입력시 상품 검색 - const onKeyDownEnter = (e) => { - if (e.key === "Enter") { - handleKeyword(searchInput); - } - }; - - return ( - - ); -} - -export default NavBar; diff --git a/src/component/Navigation/NavBar.css b/src/component/Navigation/NavBar.module.css similarity index 96% rename from src/component/Navigation/NavBar.css rename to src/component/Navigation/NavBar.module.css index a53a938bc..c1478ff11 100644 --- a/src/component/Navigation/NavBar.css +++ b/src/component/Navigation/NavBar.module.css @@ -7,7 +7,7 @@ display: flex; } -.all-item__title { +.allitem__title { flex-grow: 1; } diff --git a/src/component/Navigation/NavBar.tsx b/src/component/Navigation/NavBar.tsx new file mode 100644 index 000000000..5834fb503 --- /dev/null +++ b/src/component/Navigation/NavBar.tsx @@ -0,0 +1,50 @@ +import { Link } from "react-router-dom"; +import { ChangeEvent, KeyboardEvent, useState } from "react"; +import styles from "./NavBar.module.css"; + +function NavBar({ + handleOrder, + handleKeyword, +}: { + handleOrder: (value: string) => void; + handleKeyword: (value: string) => void; +}) { + const [searchInput, setSearchInput] = useState(""); + + const onClickOption = (e: ChangeEvent) => { + handleOrder(e.target.value); + }; + + const handleChangeInput = (e: ChangeEvent) => { + setSearchInput(e.target.value); + }; + + //엔터키 입력시 상품 검색 + const onKeyDownEnter = (e: KeyboardEvent) => { + if (e.key === "Enter") { + handleKeyword(searchInput); + } + }; + + return ( + + ); +} + +export default NavBar; diff --git a/src/component/Pagination/PageButton.js b/src/component/Pagination/PageButton.js deleted file mode 100644 index efe5087fa..000000000 --- a/src/component/Pagination/PageButton.js +++ /dev/null @@ -1,60 +0,0 @@ -import LeftArrow from "../../asset/arrow_left.png"; -import RightArrow from "../../asset/arrow_right.png"; -import "./PageButton.css"; - -function PageButton({ handlePage, page, maxPage }) { - const ClickNumber = (e) => { - handlePage(e.target.value); - }; - - const ClickIconLeft = () => { - if (page > 1) { - handlePage(page - 1); - } - }; - - const ClickIconRight = () => { - if (page < maxPage) { - handlePage(page + 1); - } - }; - - let pageIine; - pageIine = Math.floor((page - 1) / 5); - let PageGroup = []; - if (pageIine === Math.floor(maxPage / 5)) { - for (let i = 1; i <= maxPage % 5; i++) { - PageGroup.push(i + 5 * pageIine); - } - } else { - for (let i = 1; i <= 5; i++) { - PageGroup.push(i + 5 * pageIine); - } - } - - function PageButtonNumber({ value }) { - const className = - page === value ? "pagebutton-number active" : "pagebutton-number"; - return ( -
  • - {value} -
  • - ); - } - - return ( -
      -
    • - 왼쪽아이콘 -
    • - {PageGroup.map((value) => ( - - ))} -
    • - 오른쪽아이콘 -
    • -
    - ); -} - -export default PageButton; diff --git a/src/component/Pagination/PageButton.css b/src/component/Pagination/PageButton.module.css similarity index 78% rename from src/component/Pagination/PageButton.css rename to src/component/Pagination/PageButton.module.css index 5bd5d72e6..0c79669f3 100644 --- a/src/component/Pagination/PageButton.css +++ b/src/component/Pagination/PageButton.module.css @@ -1,14 +1,14 @@ -.pagebutton-container { +.pagebutton_container { display: flex; justify-content: center; gap: 4px; } -.pagebutton-icon { +.pagebutton_icon { height: 16px; } -.pagebutton-number { +.pagebutton_number { width: 40px; height: 40px; border-radius: 40px; @@ -21,7 +21,7 @@ cursor: pointer; } -.pagebutton-number.active { +.pagebutton_number .active { background-color: #2f80ed; color: white; } diff --git a/src/component/Pagination/PageButton.tsx b/src/component/Pagination/PageButton.tsx new file mode 100644 index 000000000..d3f95cbde --- /dev/null +++ b/src/component/Pagination/PageButton.tsx @@ -0,0 +1,89 @@ +import LeftArrow from "../../asset/arrow_left.png"; +import RightArrow from "../../asset/arrow_right.png"; +import styles from "./PageButton.module.css"; + +function PageButton({ + handlePage, + page, + maxPage, +}: { + handlePage: (value: number) => void; + page: number; + maxPage: number; +}) { + const ClickNumber = (e: React.MouseEvent) => { + handlePage(Number(e.currentTarget.dataset.value)); + }; + + const ClickIconLeft = () => { + if (page > 1) { + handlePage(page - 1); + } + }; + + const ClickIconRight = () => { + if (page < maxPage) { + handlePage(page + 1); + } + }; + + let pageIine; + pageIine = Math.floor((page - 1) / 5); + let PageGroup = []; + if (pageIine === Math.floor(maxPage / 5)) { + for (let i = 1; i <= maxPage % 5; i++) { + PageGroup.push(i + 5 * pageIine); + } + } else { + for (let i = 1; i <= 5; i++) { + PageGroup.push(i + 5 * pageIine); + } + } + + let background = ""; + let color = ""; + function PageButtonNumber({ value }: { value: number }) { + if (page === value) { + background = "#2f80ed"; + color = "white"; + } else { + background = ""; + color = ""; + } + + return ( +
  • + {value} +
  • + ); + } + + return ( +
      +
    • + 왼쪽아이콘 +
    • + {PageGroup.map((value) => ( + + ))} +
    • + 오른쪽아이콘 +
    • +
    + ); +} + +export default PageButton; diff --git a/src/component/ProductComment/ProductComment.module.css b/src/component/ProductComment/ProductComment.module.css new file mode 100644 index 000000000..c09fe8544 --- /dev/null +++ b/src/component/ProductComment/ProductComment.module.css @@ -0,0 +1,56 @@ +.comment_container { + display: flex; + gap: 24px; + flex-direction: column; + position: relative; + height: 100px; + border-bottom: 1px solid #e5e7eb; +} + +.comment_content { + font-size: 14px; + font-weight: 400; + line-height: 24px; + color: #1f2937; +} + +.user_wrap { + display: flex; + /* align-items: center; */ + gap: 8px; +} + +.user_img { + width: 32px; + height: 32px; + object-fit: cover; +} + +.user_name_wrap { + display: flex; + flex-direction: column; + gap: 4px; +} + +.user_name { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: #4b5563; +} + +.user_time { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: #9ca3af; +} + +.kebabicon { + width: 24px; + height: 24px; + object-fit: cover; + position: absolute; + top: 0; + right: 0; +} diff --git a/src/component/ProductComment/ProductComment.tsx b/src/component/ProductComment/ProductComment.tsx new file mode 100644 index 000000000..8e2d019c0 --- /dev/null +++ b/src/component/ProductComment/ProductComment.tsx @@ -0,0 +1,45 @@ +import userIcon from "../../asset/userIcon.png"; +import kebabIcon from "../../asset/ic_kebab.png"; +import styles from "./ProductComment.module.css"; + +interface Value { + writer: { + image: string; + nickname: string; + id: number; + }; + updatedAt: string; + createdAt: string; + content: string; + id: number; +} + +function ProductComment({ id, value }: { id: number; value: Value }) { + //마지막 업데이트 시간 계산 + + // date1과 date2 문자열을 Date 객체로 변환합니다. + const startDate = new Date(value.updatedAt); + const endDate = new Date(); + // 두 날짜 간의 시간 차이를 밀리초 단위로 계산합니다. + const diffTime = endDate.getTime() - startDate.getTime(); + // 밀리초를 일 단위로 변환합니다. + const diffDays = diffTime / (1000 * 60 * 60 * 24); + // 일수 차이를 반환합니다. + console.log(Math.floor(diffDays)); + + return ( +
    +

    {value.content}

    +
    + 유저아이콘 +
    + {value.writer.nickname} +

    {Math.floor(diffDays)}일 전

    +
    +
    + 케밥아이콘 +
    + ); +} + +export default ProductComment; diff --git a/src/component/ProductTag.js b/src/component/ProductTag.js deleted file mode 100644 index 57a417d70..000000000 --- a/src/component/ProductTag.js +++ /dev/null @@ -1,21 +0,0 @@ -import iconX from "../asset/ic_X.png"; -import styles from "./ProductTag.module.css"; - -function ProductTag({ value, handlechangeTagList }) { - const handleClickTag = () => { - handlechangeTagList(value); - }; - return ( -
    -

    #{value}

    - 태그 삭제 아이콘 -
    - ); -} - -export default ProductTag; diff --git a/src/component/ProductTag.tsx b/src/component/ProductTag.tsx new file mode 100644 index 000000000..7e5050a18 --- /dev/null +++ b/src/component/ProductTag.tsx @@ -0,0 +1,35 @@ +import iconX from "../asset/ic_X.png"; +import styles from "./ProductTag.module.css"; + +function ProductTag({ + value, + handlechangeTagList, + disable = true, +}: { + value: string; + handlechangeTagList?: (id: string) => void; + disable?: boolean; +}) { + const handleClickTag = () => { + if (handlechangeTagList) { + handlechangeTagList(value); + } + }; + return ( +
    +

    #{value}

    + {disable ? ( + 태그 삭제 아이콘 + ) : ( + "" + )} +
    + ); +} + +export default ProductTag; diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 000000000..31ba36a60 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,10 @@ +declare module "*.module.css" { + // module.css 로 끝나는 모든 파일을 module 로 인식 + const content: { [key: string]: string }; // 해당 모듈파일의 타입 객체 생성 + export = content; // 해당 타입 객체를 전역에 export +} + +declare module "*.jpg"; +declare module "*.png"; +declare module "*.jpeg"; +declare module "*.gif"; diff --git a/src/pages/MainPage.js b/src/pages/MainPage.tsx similarity index 100% rename from src/pages/MainPage.js rename to src/pages/MainPage.tsx diff --git a/src/pages/PostProductPage.js b/src/pages/PostProductPage.tsx similarity index 88% rename from src/pages/PostProductPage.js rename to src/pages/PostProductPage.tsx index 85a14cd22..d0aca2cb8 100644 --- a/src/pages/PostProductPage.js +++ b/src/pages/PostProductPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { ChangeEvent, KeyboardEvent, useEffect, useRef, useState } from "react"; import styles from "./PostProductPage.module.css"; import ProductTag from "../component/ProductTag"; import iconX from "../asset/ic_X.png"; @@ -6,25 +6,25 @@ import iconX from "../asset/ic_X.png"; function PostProductPage() { const [preview, setPreview] = useState(""); const [imgError, setImgError] = useState(false); - const [tagList, setTagList] = useState([]); + const [tagList, setTagList] = useState([]); //value값들 const [nameInput, setnameInput] = useState(""); const [desInput, setDesInput] = useState(""); const [priceInput, setPriceInput] = useState(""); const [tagValue, setTagValue] = useState(""); - const inputRef = useRef(); + const inputRef = useRef(null); - const handleChangeNameInput = (e) => { + const handleChangeNameInput = (e: ChangeEvent) => { setnameInput(e.target.value); }; - const handleChangeDesInput = (e) => { + const handleChangeDesInput = (e: ChangeEvent) => { setDesInput(e.target.value); }; //가격 input 함수 - const handleChangePriceInput = (e) => { + const handleChangePriceInput = (e: ChangeEvent) => { let number = e.target.value; //숫자 이외에 입력은 공백으로 바꿔줌 number = number.replace(/[^0-9]/g, ""); @@ -40,7 +40,10 @@ function PostProductPage() { : `${styles.post_button}`; //파일 input 함수 - const handleChangeFile = (e) => { + const handleChangeFile = (e: ChangeEvent) => { + if (!e.target.files) { + return; + } if (preview) { setImgError(true); return; @@ -63,7 +66,7 @@ function PostProductPage() { }; //태그 input 함수 - const handleChangeTag = (e) => { + const handleChangeTag = (e: KeyboardEvent) => { const isValue = tagList.find((element) => element === tagValue); if (e.keyCode === 13 && isValue) { alert("이미 있는 태그입니다."); @@ -74,18 +77,18 @@ function PostProductPage() { } }; - const handleChangeTagInput = (e) => { + const handleChangeTagInput = (e: ChangeEvent) => { setTagValue(e.target.value); }; - const handlechangeTagList = (id) => { + const handlechangeTagList = (id: string) => { const nextTagList = tagList.filter((element) => element !== id); setTagList(nextTagList); }; //폼 제출기능 막기 - const preventSubmit = (e) => { - // e.preventDefault(); + const preventSubmit = (e: React.FormEvent) => { + e.preventDefault(); }; useEffect(() => { diff --git a/src/pages/ProductDetail.module.css b/src/pages/ProductDetail.module.css new file mode 100644 index 000000000..8bb5acab0 --- /dev/null +++ b/src/pages/ProductDetail.module.css @@ -0,0 +1,247 @@ +.detail_section { + display: flex; + justify-content: center; + align-items: center; + height: 1300px; +} + +.detail_wrap { + max-width: 1200px; +} + +/* 상품 영역 */ +.product_container { + display: flex; + gap: 24px; + align-items: center; +} + +.product_img { + width: 486px; + height: 486px; + object-fit: cover; + border-radius: 16px; +} + +.product_title_wrap, +.product_des_wrap, +.product_tag_wrap { + display: flex; + flex-direction: column; + gap: 16px; +} + +.product_title { + font-size: 24px; + font-weight: 600; + color: #1f2937; + line-height: 32px; +} + +.product_price { + font-size: 40px; + font-weight: 600; + color: #1f2937; + line-height: 47px; +} + +.product_line { + height: 1px; + background-color: #e5e7eb; +} + +.product_description_title, +.product_tag_title { + font-size: 16px; + font-weight: 600; + color: #4b5563; + line-height: 26px; +} + +.product_tag_list { + display: flex; + gap: 8px; +} + +.product_description { + font-size: 16px; + font-weight: 400; + color: #4b5563; + line-height: 26px; + flex-grow: 1; +} + +.product_detail_section { + display: flex; + flex-direction: column; + gap: 24px; +} + +.product_wrap { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 690px; + height: 496px; +} + +.product_user_wrap { + display: flex; + align-items: center; + gap: 16px; +} + +.user_img { + object-fit: cover; + width: 40px; + height: 40px; +} + +.user_name { + font-weight: 500; + font-size: 14px; + line-height: 24px; + color: #4b5563; +} + +.user_date { + font-weight: 400; + font-size: 14px; + line-height: 24px; + color: #9ca3af; +} + +/* 폼 영역 */ + +.comment_input_container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.comment_input_wrap { + display: flex; + flex-direction: column; + gap: 9px; +} + +.comment_input_title { + font-weight: 600; + font-size: 16px; + line-height: 26px; + color: #111827; +} + +.comment_input { + max-width: 1200px; + height: 104px; + background-color: #f3f4f6; + padding: 16px 24px; + border-radius: 12px; + border: none; + resize: none; + font-weight: 400; + font-size: 16px; + line-height: 26px; + font-family: "Pretendard Variable", Pretendard; +} + +.comment_input::placeholder { + font-weight: 400; + font-size: 16px; + line-height: 26px; + color: #9ca3af; + font-family: "Pretendard Variable", Pretendard; +} + +.comment_button { + width: 72px; + height: 42px; + background-color: #9ca3af; + border-radius: 8px; + color: white; + border: none; + margin-left: auto; +} + +.comment_button_hover { + width: 72px; + height: 42px; + background-color: #3692ff; + border-radius: 8px; + color: white; + border: none; + margin-left: auto; +} + +.detail_line { + height: 1px; + max-width: 1190px; + background-color: #e5e7eb; + margin: 40px 0; +} + +.comment_list_container { + display: flex; + flex-direction: column; + gap: 24px; + margin-top: 24px; +} + +.heart { + width: 32px; + height: 32px; + object-fit: cover; +} + +.button_container { + display: flex; + justify-content: center; + margin-top: 64px; +} + +.button_to_list { + padding: 12px 64px; + border-radius: 40px; + border: none; + background-color: #3692ff; + color: white; + display: flex; + align-items: center; + gap: 8px; + font-weight: 400; + font-size: 18px; + line-height: 26px; + cursor: pointer; +} + +.button_to_list:hover { + background-color: #8bbfff; +} + +.button_backicon { + width: 24px; + height: 24px; + object-fit: cover; +} + +.comment_no_wrap { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; +} + +.comment_no_img { + width: 196px; + height: 196px; + object-fit: cover; +} + +.comment_no_des { + font-weight: 400px; + font-size: 14px; + line-height: 26px; + color: #9ca3af; +} diff --git a/src/pages/ProductDetail.tsx b/src/pages/ProductDetail.tsx new file mode 100644 index 000000000..b3422cd8b --- /dev/null +++ b/src/pages/ProductDetail.tsx @@ -0,0 +1,207 @@ +import { Link, useParams } from "react-router-dom"; +import styles from "./ProductDetail.module.css"; +import UserImg from "../asset/userIcon.png"; +import ProductTag from "../component/ProductTag"; +import ProductComment from "../component/ProductComment/ProductComment"; +import heartIcon from "../asset/ic_heart.png"; +import { SyntheticEvent, useEffect, useState } from "react"; +import { getProductComment, getProductDetail } from "../api"; +import noPhotoImg from "../asset/nophoto.png"; +import backIcon from "../asset/ic_back.png"; +import noCommentImg from "../asset/Img_inquiry_empty.png"; + +interface Product { + id: number; + name: string; + price: number; + description: string; + tags: string[]; + images: string[]; + createdAt: string; + ownerNickname: string; + favoriteCount: number; +} + +interface Comment { + writer: { + image: string; + nickname: string; + id: number; + }; + updatedAt: string; + createdAt: string; + content: string; + id: number; +} + +function ProductDetail() { + const [item, setItem] = useState(); + const [comment, setComment] = useState([]); + const [input, setInput] = useState(""); + const { productSlug } = useParams(); + + const handleChangeInput = (e: React.ChangeEvent) => { + setInput(e.target.value); + }; + + const handleLoad = async () => { + if (!productSlug) { + console.error("productSlug키 없음"); + return; + } + try { + const result = await getProductDetail(productSlug); + const { list } = await getProductComment({ productSlug, limit: 3 }); + if (!result) { + console.log("제품 정보를 찾을수 없음"); + return; + } + setItem(result); + setComment(list || []); + } catch (error) { + console.log("데이터를 로드하는 중 오류 발생"); + } + }; + + //이미지 링크가 잘못된 링크일 때, 기본 이미지 출력 + const onErrorImg = (e: SyntheticEvent) => { + e.currentTarget.src = noPhotoImg; + }; + + //날짜 + let date = new Date(); + if (item) { + date = new Date(item.createdAt); + } + + //등록 버튼 hover + let ButtonStyle = input + ? `${styles.comment_button_hover}` + : `${styles.comment_button}`; + + useEffect(() => { + handleLoad(); + }, []); + console.log(comment); + + return ( +
    + {item ? ( +
    +
    + {item.images[0] ? ( + 상품이미지 + ) : ( + 상품이미지 + )} +
    +
    +
    +

    {item.name}

    +

    + {item.price.toLocaleString()} +

    +
    +
    +
    + + 상품 소개 + +

    + {item.description} +

    +
    +
    + 상품 태그 +
    + {item.tags.map((value) => ( + + ))} +
    +
    +
    +
    + 유저이미지 +
    + {item.ownerNickname} +

    + {date.toLocaleDateString()} +

    +
    +
    + 관심아이콘 + + {item.favoriteCount} + +
    +
    +
    +
    +
    +
    +
    + +