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 ( + + + 상품 등록하기 + + 등록 + + + + + 상품 이미지 + + + + 상품명 + + + + 상품 소개 + + + + 판매가격 + + + + 태그 + + + {values.tags.map((tag) => ( + + #{tag} + handleTagRemove(tag)} + > + + + + ))} + + + + + ); +} + +export default AddItemForm; diff --git a/src/components/AllItem.tsx b/src/components/AllItem.tsx new file mode 100644 index 000000000..76770144a --- /dev/null +++ b/src/components/AllItem.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import ItemCard from "./AllItemCard"; +import { getProducts } from "../api"; +import Pagination from "./Pagination"; +import "./common.css"; +import "./AllItem.css"; +import searchIcon from "../assets/ic_search.svg"; +import { Product } from "../types"; + +const getPageSize = () => { + const width = window.innerWidth; + if (width < 768) { + return 4; + } else if (width < 1280) { + return 6; + } else { + return 10; + } +}; + +function AllItems() { + const [orderBy, setOrderBy] = useState("recent"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(getPageSize()); + const [items, setItems] = useState([]); + const [isDropdown, setIsDropdown] = useState(false); + const [totalPageNum, setTotalPageNum] = useState(0); + + useEffect(() => { + const handleFixSize = () => { + setPageSize(getPageSize()); + }; + + const fetchProducts = async ({ + orderBy, + page, + pageSize, + }: { + orderBy: string; + page: number; + pageSize: number; + }) => { + const products = await getProducts({ orderBy, page, pageSize }); + setItems(products.list); + setTotalPageNum(Math.ceil(products.totalCount / pageSize)); + }; + + window.addEventListener("resize", handleFixSize); + fetchProducts({ orderBy, page, pageSize }); + + return () => { + window.removeEventListener("resize", handleFixSize); + }; + }, [orderBy, page, pageSize]); + + const handleNextPage = (newPage: number) => { + setPage(newPage); + }; + + const toggleDropdown = () => { + setIsDropdown(!isDropdown); + }; + + const handleOrderByChange = (newOrderBy: string) => { + setOrderBy(newOrderBy); + setPage(1); + setIsDropdown(false); + }; + + return ( + + + + + 전체 상품 + + + + + + + + + + 상품 등록하기 + + + + {orderBy === "recent" ? "최신순" : "좋아요순"} ▼ + + {isDropdown && ( + + handleOrderByChange("recent")}> + 최신순 + + handleOrderByChange("favorite")}> + 좋아요순 + + + )} + + + + + {items?.map((item) => ( + + ))} + + + + + ); +} + +export default AllItems; diff --git a/src/components/AllItemCard.tsx b/src/components/AllItemCard.tsx new file mode 100644 index 000000000..f9045dd45 --- /dev/null +++ b/src/components/AllItemCard.tsx @@ -0,0 +1,39 @@ +import favoriteIcon from "../assets/Icon.svg"; +import defaultImg from "../assets/-error-outline_90275.png"; +import "./AllItemCard.css"; +import "./common.css"; +import { Link } from "react-router-dom"; +import { Product } from "../types"; + +interface AllItemCardProps { + item: Product; +} + +function AllItemCard({ item }: AllItemCardProps) { + const handleNoneImg = (e: React.SyntheticEvent) => { + e.currentTarget.src = defaultImg; + }; + + return ( + + + + + + {item.name} + {item.price.toLocaleString()}원 + + + {item.favoriteCount} + + + + ); +} + +export default AllItemCard; diff --git a/src/components/BestItem.tsx b/src/components/BestItem.tsx new file mode 100644 index 000000000..1b5329a76 --- /dev/null +++ b/src/components/BestItem.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import { getProducts } from "../api"; +import BestItemCard from "./BestItemCard"; +import "./BestItem.css"; +import "./common.css"; +import { Product, GetProductsResponse } from "../types"; + +const getPageSize = () => { + const width = window.innerWidth; + if (width < 768) { + return 1; + } else if (width < 1280) { + return 2; + } else { + return 4; + } +}; + +const debounce = (func: (...args: any[]) => void, delay: number) => { + let timeoutId: NodeJS.Timeout; + return (...args: any[]) => { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + func(...args); + }, delay); + }; +}; + +function BestItem() { + const [items, setItems] = useState([]); + const [pageSize, setPageSize] = useState(getPageSize); + + const fetchProducts = async ({ + orderBy, + pageSize, + }: { + orderBy: string; + pageSize: number; + }) => { + const products: GetProductsResponse = await getProducts({ + orderBy, + pageSize, + }); + setItems(products.list); + }; + + useEffect(() => { + const handleFixSize = debounce(() => { + setPageSize(getPageSize()); + }, 300); // 300ms 딜레이로 debounce 적용 + + window.addEventListener("resize", handleFixSize); + fetchProducts({ orderBy: "favorite", pageSize }); + + return () => { + window.removeEventListener("resize", handleFixSize); + }; + }, [pageSize]); + + return ( + + + 베스트 상품 + + {items?.map((item) => ( + + ))} + + + + ); +} + +export default BestItem; diff --git a/src/components/BestItemCard.tsx b/src/components/BestItemCard.tsx new file mode 100644 index 000000000..0aa2b8dca --- /dev/null +++ b/src/components/BestItemCard.tsx @@ -0,0 +1,29 @@ +import { Link } from "react-router-dom"; +import favoriteIcon from "../assets/Icon.svg"; +import "./BestItemCard.css"; +import "./common.css"; +import { Product } from "../types"; + +interface BestItemCardProps { + item: Product; +} + +function BestItemCard({ item }: BestItemCardProps) { + return ( + + + + + + {item.name} + {item.price.toLocaleString()}원 + + + {item.favoriteCount} + + + + ); +} + +export default BestItemCard; diff --git a/src/components/FileInput.css b/src/components/FileInput.css index 315daa58e..5c2bf6d3c 100644 --- a/src/components/FileInput.css +++ b/src/components/FileInput.css @@ -22,7 +22,7 @@ } .file-input-label:hover { - background-color: #fcfcfc; + background-color: #fafafa; } .file-input-hidden { diff --git a/src/components/FileInput.tsx b/src/components/FileInput.tsx new file mode 100644 index 000000000..7c1faee62 --- /dev/null +++ b/src/components/FileInput.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from "react"; +import resetImg from "../assets/ic_X.svg"; +import "./FileInput.css"; +import plusIcon from "../assets/ic_plus.svg"; + +interface FileInputProps { + className?: string; + name: string; + value: File | null; + initialPreview?: string | null; + onChange: (name: string, file: File | null) => void; +} + +function FileInput({ + className = "", + name, + value, + initialPreview = null, + onChange, +}: FileInputProps) { + const [preview, setPreview] = useState(initialPreview); + const [errorMessage, setErrorMessage] = useState(""); + const inputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile) { + if (value) { + setErrorMessage("*이미지 등록은 최대 1개까지 가능합니다."); + return; + } + const previewUrl = URL.createObjectURL(selectedFile); + setPreview(previewUrl); + onChange(name, selectedFile); + setErrorMessage(""); + } + }; + + const handleClearClick = () => { + if (inputRef.current) { + inputRef.current.value = ""; + setPreview(null); + onChange(name, null); + setErrorMessage(""); + } + }; + + const handleUploadClick = (e: React.MouseEvent) => { + if (value) { + e.preventDefault(); // 파일 선택 창이 열리지 않도록 방지 + setErrorMessage("*이미지 등록은 최대 1개까지 가능합니다."); + } + }; + + useEffect(() => { + if (value) { + const previewUrl = URL.createObjectURL(value); + setPreview(previewUrl); + return () => URL.revokeObjectURL(previewUrl); + } else { + setPreview(null); + } + }, [value]); + + return ( + + + + + + + 이미지 등록 + + + {value && ( + + + + + + + )} + + {errorMessage && ( + {errorMessage} + )} + + ); +} + +export default FileInput; diff --git a/src/components/ItemComment.tsx b/src/components/ItemComment.tsx new file mode 100644 index 000000000..cf741595d --- /dev/null +++ b/src/components/ItemComment.tsx @@ -0,0 +1,131 @@ +import "./ItemComment.css"; +import { getProductComments } from "../api"; +import { useState, useEffect } from "react"; +import { useParams } from "react-router-dom"; +import defaultImage from "../assets/Frame 2609463.png"; +import moreIcon from "../assets/Group 33735.svg"; + +interface Comment { + id: number; + content: string; + createdAt: string; + updatedAt: string; + writer: { + id: number; + image: string; + nickname: string; + }; +} + +const timeAgo = (dateString: string) => { + const now = new Date(); + const past = new Date(dateString); + const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000); + const diffInDays = Math.floor(diffInSeconds / (60 * 60 * 24)); + + return `${diffInDays}일 전`; +}; + +function ItemComments() { + const { productId } = useParams<{ productId: string }>(); + const [comments, setComments] = useState([]); + const [inputValue, setInputValue] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [showMore, setShowMore] = useState>({}); + + useEffect(() => { + const fetchComments = async () => { + if (!productId) return; + try { + const data = await getProductComments(productId); + setComments(data.list); + setLoading(false); + } catch (err) { + setError("댓글을 불러오는 데 실패했습니다."); + setLoading(false); + } + }; + + fetchComments(); + }, [productId]); + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; + + const handleMoreClick = (commentId: number) => { + setShowMore((prevState) => ({ + ...prevState, + [commentId]: !prevState[commentId], + })); + }; + + if (loading) return 댓글을 불러오는 중입니다...; + if (error) return {error}; + + return ( + + 문의하기 + + + + 등록 + + + + + {comments.length > 0 ? ( + comments.map((comment, index) => ( + + + {comment.content} + + handleMoreClick(comment.id)} + /> + {showMore[comment.id] && ( + + 수정하기 + + 삭제하기 + + + )} + + + + + + + + {comment.writer.nickname}{" "} + + + {timeAgo(comment.updatedAt)} + + + + + )) + ) : ( + 댓글이 없습니다. + )} + + + ); +} + +export default ItemComments; diff --git a/src/components/ItemDetailForm.tsx b/src/components/ItemDetailForm.tsx new file mode 100644 index 000000000..496170af0 --- /dev/null +++ b/src/components/ItemDetailForm.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { getProductDetail } from "../api"; +import favoriteIcon from "../assets/Icon.svg"; +import ItemComments from "./ItemComment"; +import profileIcon from "../assets/Frame 2609463.png"; +import moreIcon from "../assets/Group 33735.svg"; +import backIcon from "../assets/ic_back.svg"; +import "./ItemDetailForm.css"; + +interface ProductDetail { + id: number; + name: string; + description: string; + price: number; + createdAt: string; + favoriteCount: number; + ownerNickname: string; + ownerId: number; + images: string[]; + tags: string[]; +} + +function ItemDetailForm() { + const { productId } = useParams<{ productId: string }>(); + const navigate = useNavigate(); + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const dateOnly = + item && item.createdAt + ? item.createdAt.split("T")[0].replace(/-/g, ".") + : ""; + + useEffect(() => { + const fetchProductDetail = async () => { + if (!productId) { + setError("상품 ID가 없습니다."); + return; + } + setLoading(true); + try { + const data = await getProductDetail(productId); + setItem(data); + } catch (error) { + setError("상품 정보를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + fetchProductDetail(); + }, [productId]); + + const handleGoBack = () => { + navigate("/items"); + }; + + if (loading) { + return 로딩 중...; + } + + if (error) { + return {error}; + } + + if (!item) { + return 상품 정보를 찾을 수 없습니다.; + } + + return ( + + + + + + {item.images && item.images.length > 0 ? ( + item.images.map((image, index) => ( + + )) + ) : ( + 이미지가 없습니다. + )} + + + + + + + {item.name} + + + + {item.price.toLocaleString()}원 + + + + 상품 소개 + {item.description} + + + + + + 상품 태그 + + {" "} + {item.tags && item.tags.length > 0 ? ( + item.tags.map((tag, index) => ( + + #{tag} + + )) + ) : ( + 태그가 없습니다. + )} + + + + + + + + + {item.ownerNickname} + + {dateOnly} + + + + {" "} + + + {item.favoriteCount} + + + + + + + + + + + + + + + + + + 목록으로 돌아가기 + + + + + + + ); +} + +export default ItemDetailForm; diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx new file mode 100644 index 000000000..84923887e --- /dev/null +++ b/src/components/Nav.tsx @@ -0,0 +1,42 @@ +import { useLocation } from "react-router-dom"; +import logoLarge from "../assets/Property 1=lg.png"; +import profileIcon from "../assets/Frame 2609463.png"; +import "./common.css"; +import "./Nav.css"; + +const Nav = () => { + const location = useLocation(); + + return ( + + + + + + + + + 자유게시판 + + + 중고마켓 + + + + + + + + + ); +}; + +export default Nav; diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 000000000..177903cd5 --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import "./pagination.css"; +import prevIcon from "../assets/arrow_left.svg"; +import nextIcon from "../assets/arrow_right.svg"; + +interface PaginationProps { + currentPage: number; + totalPageNum: number; + onPageChange: (page: number) => void; +} + +function Pagination({ + currentPage, + totalPageNum, + onPageChange, +}: PaginationProps) { + const pageNumbers = []; + const maxVisiblePages = 5; // 한 번에 표시할 최대 페이지 수 + + // 현재 페이지가 속한 페이지 그룹 계산 + const currentGroup = Math.floor((currentPage - 1) / maxVisiblePages); + const startPage = currentGroup * maxVisiblePages + 1; + const endPage = Math.min(startPage + maxVisiblePages - 1, totalPageNum); + + for (let i = startPage; i <= endPage; i++) { + pageNumbers.push(i); + } + + // 다음 5개로 이동하는 함수 + const goToNextGroup = () => { + const nextGroupPage = startPage + maxVisiblePages; + if (nextGroupPage <= totalPageNum) { + onPageChange(nextGroupPage); + } + }; + + // 이전 5개로 이동하는 함수 + const goToPreviousGroup = () => { + const prevGroupPage = startPage - maxVisiblePages; + if (prevGroupPage > 0) { + onPageChange(prevGroupPage); + } + }; + + return ( + + + + + {pageNumbers.map((number) => ( + onPageChange(number)} + > + {number} + + ))} + + + + + ); +} + +export default Pagination; diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 000000000..60379c94d --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,14 @@ +declare module "*.png" { + const value: string; + export default value; +} + +declare module "*.jpg" { + const value: string; + export default value; +} + +declare module "*.svg" { + const content: string; + export default content; +} diff --git a/src/hooks/useAsync.tsx b/src/hooks/useAsync.tsx new file mode 100644 index 000000000..c25bc5908 --- /dev/null +++ b/src/hooks/useAsync.tsx @@ -0,0 +1,27 @@ +import { useCallback, useState } from "react"; + +function useAsync Promise>( + asyncFunction: T +) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const wrappedFunction = useCallback( + async (...args: Parameters): Promise | undefined> => { + try { + setError(null); + setPending(true); + return await asyncFunction(...args); + } catch (error) { + setError(error as any); + return; + } finally { + setPending(false); + } + }, + [asyncFunction] + ); + return [pending, error, wrappedFunction]; +} + +export default useAsync; diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 000000000..ac4bb3d9c --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; + +const root = ReactDOM.createRoot(document.getElementById("root")!); +root.render( + + + +); diff --git a/src/layout.tsx b/src/layout.tsx new file mode 100644 index 000000000..abfd1c833 --- /dev/null +++ b/src/layout.tsx @@ -0,0 +1,13 @@ +import Nav from "./components/Nav"; +import { Outlet } from "react-router-dom"; + +function Layout() { + return ( + <> + + + > + ); +} + +export default Layout; diff --git a/src/pages/AddItem.tsx b/src/pages/AddItem.tsx new file mode 100644 index 000000000..107dc09b7 --- /dev/null +++ b/src/pages/AddItem.tsx @@ -0,0 +1,50 @@ +import AddItemForm from "../components/AddItemForm"; +import axios from "axios"; + +interface Item { + id: number; + name: string; + description: string; + price: number; + createdAt: string; + favoriteCount: number; + ownerNickname: string; + ownerId: number; + images: string[]; + tags: string[]; +} + +interface ItemListResponse { + totalCount: number; + list: Item[]; +} + +function AddItem() { + const handleSubmit = async ( + formData: FormData + ): Promise<{ review: Item } | null> => { + try { + const response = await axios.post( + "https://panda-market-api.vercel.app/products", + formData + ); + const itemListResponse: ItemListResponse = response.data; + return { review: itemListResponse.list[0] }; + } catch (error) { + console.error("Failed to submit product:", error); + return null; + } + }; + + const handleSuccess = (item: Item) => { + console.log("Product submitted successfully:", item); + }; + + return ( + + + + ); +} + +export default AddItem; diff --git a/src/pages/ItemDetail.tsx b/src/pages/ItemDetail.tsx new file mode 100644 index 000000000..2fca8b875 --- /dev/null +++ b/src/pages/ItemDetail.tsx @@ -0,0 +1,11 @@ +import ItemDetailForm from "../components/ItemDetailForm"; + +function ItemDetail() { + return ( + + + + ); +} + +export default ItemDetail; diff --git a/src/pages/Items.tsx b/src/pages/Items.tsx new file mode 100644 index 000000000..a3f0407ac --- /dev/null +++ b/src/pages/Items.tsx @@ -0,0 +1,13 @@ +import AllItems from "../components/AllItem"; +import BestItem from "../components/BestItem"; + +function Items() { + return ( + + + + + ); +} + +export default Items; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..9ed8d0cee --- /dev/null +++ b/src/types.ts @@ -0,0 +1,63 @@ +export interface GetProductsParams { + page?: number; + pageSize?: number; + keyword?: string | null; + orderBy?: string; +} + +export interface GetProductCommentsParams { + limit?: number; + cursor?: string | null; +} + +// 상품 타입 정의 +export interface Product { + id: number; + name: string; + price: number; + description?: string; + images: string[]; + favoriteCount?: number; +} + +// 상품 목록 응답 타입 정의 +export interface GetProductsResponse { + list: Product[]; + total: number; + page: number; + pageSize: number; + totalCount: number; +} + +// 상품 상세 정보 응답 타입 정의 +export interface ProductDetail { + id: number; + name: string; + description: string; + price: number; + createdAt: string; + favoriteCount: number; + ownerNickname: string; + ownerId: number; + images: string[]; + tags: string[]; +} + +// 댓글 타입 정의 +export interface Comment { + id: number; + content: string; + createdAt: string; + updatedAt: string; + writer: { + id: number; + image: string; + nickname: string; + }; +} + +// 댓글 목록 응답 타입 정의 +export interface GetCommentsResponse { + nextCursor: number; + list: Comment[]; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..6f8ece8a6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,118 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": true, + "outDir": "./dist", + "moduleResolution": "Node", + "lib": ["ES2015", "DOM", "DOM.Iterable"], + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + "resolveJsonModule": true, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "include": ["./src/**/*"], + "exclude": ["node_modules", "dist"] +}
{item.description}