diff --git a/README.md b/README.md index f768e33f..6370b2bd 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,189 @@ -# React + Vite +# 판다 마켓 - 중고 거래 플랫폼 -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +## 미션 3 요구사항 -Currently, two official plugins are available: +### 기본 -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +- [x] 랜딩 페이지의 url path는 루트(‘/’) 입니다. +- [x] title은 “판다마켓”으로 설정해 주세요. +- [x] 화면의 너비가 1920px 이상이면 하늘색 배경색은 너비를 꽉 채우도록 채워지고, 내부 요소들의 위치는 고정되고, 여백만 커지도록 해주세요. +- [x] 화면의 너비가 1920px 보다 작아질 때, “판다마켓” 로고의 왼쪽 여백 200px“로그인" 버튼의 오른쪽 여백 200px이 유지되고, 화면의 너비가 작아질수록 두 요소간 거리가 가까워지도록 해주세요. +- [x] 클릭으로 기능이 동작해야 하는 경우, 사용자가 클릭할 수 있는 요소임을 알 수 있도록 cursor: pointer를 설정해 주세요. +- [x] “판다마켓” 클릭 시 루트 페이지(‘/’)로 이동시켜주세요. +- [x] “구경하러 가기" 클릭 시 (“/items”)페이지로 이동시켜주세요.(빈 페이지) +- [x] “로그인”버튼 클릭 시 로그인 페이지(‘/login’)로 이동합니다 +- [x] “구경하러가기”버튼 클릭 시(’/items’)로 이동합니다 +- [x] 페이스북, 트위터, 유튜브, 인스타그램 아이콘은 클릭 시 각각의 홈페이지로 새로운 창이 열리면서 이동 합니다 +- [x] “Privacy Policy”, “FAQ”는 클릭 시 각각 아래 페이지로 이동합니다- Privacy 페이지(‘/privacy’) - FAQ 페이지(‘/faq’) + +--- + +- [x] 아래로 스크롤 해도 상단 네비게이션 바(Global Navigation Bar)가 최상단에 고정됩니다. +- [x] “판다마켓" 클릭 시 루트 페이지(“/”)로 이동합니다. (새로고침) +- [x] 로그인 페이지, 회원가입 페이지 모두 로고 위 상단 여백이 동일합니다. +- [x] SNS 아이콘들은 클릭시 각각 아래 페이지로 이동합니다.- “https://www.google.com/”, “https://www.kakaocorp.com/page/” +- [x] “회원 가입”버튼 클릭 시 “/signup” 페이지로 이동합니다. + +--- + +- [x] 브라우저에 현재 보이는 화면의 영역(viewport) 너비를 기준으로 분기되는 반응형 디자인을 적용합니다.- PC: 1200px 이상- Tablet: 768px 이상 ~ 1199px 이하- Mobile: 375px 이상 ~ 767px 이하\* 375px 미만 사이즈의 디자인은 고려하지 않습니다 +- [x] Tablet 사이즈로 작아질 때 “판다마켓” 로고의 왼쪽에 여백 24px, “로그인” 버튼 오른쪽 여백 24px을 유지할 수 있도록 “판다마켓” 로고와 “로그인" 버튼의 간격이 가까워집니다. +- [x] Mobile 사이즈로 작아질 때 “판다마켓” 로고의 왼쪽에 여백 16px, “로그인” 버튼 오른쪽 여백 16px을 유지할 수 있도록 “판다마켓” 로고와 “로그인" 버튼의 간격이 가까워집니다. +- [x] Tablet 사이즈에서 내부 디자인은 PC사이즈와 동일합니다. +- [x] Mobile 사이즈에서 좌우 여백 16px 제외하고 내부 요소들이 너비를 모두 차지합니다. +- [x] Mobile 사이즈에서 내부 요소들의 너비는 기기의 너비가 커지는 만큼 커지지만 400px을 넘지 않습니다. +- [x] Mobile 사이즈에서 좌우 여백 16px 제외하고 내부 요소들이 너비를 모두 차지합니다. +- [x] Mobile 사이즈에서 내부 요소들의 너비는 기기의 너비가 커지는 만큼 커지지만 400px을 넘지 않습니다. + +### 심화 + +- [x] 사용자의 브라우저가 크고 작아짐에 따라 페이지의 요소간 간격, 요소의 크기, font-size 등 모든 크기와 관련된 값이 크고 작아지도록 설정해 보세요.(설정값은 자유입니다) + +--- + +- [x] palette에 있는 color값들을 css 변수로 등록하고 사용합니다. + +--- + +- [x] 페이스북, 카카오톡, 디스코드, 트위터 등 SNS에서 Linkbrary 랜딩 페이지(“/”) 공유 시 좌측 예시와 같은 미리보기를 볼 수 있도록 랜딩 페이지 메타 태그를 설정해 주세요. +- [x] 미리보기에서 제목은 “판다 마켓”, 설명은 “일상의 모든 물건을 거래해보세요”로 설정합니다. +- [x] 주소와 이미지는 자유롭게 설정하세요. + +### 주요 변경사항 + +- Netlify에서 로고 이미지가 보이게 수정했습니다. + +### 스크린샷 + +**메인화면** +| PC | Tablet | Mobile | +| :-: | :----: | :----: | +|![pc_main](https://github.com/user-attachments/assets/ab4254e0-c732-49b6-8eb3-becf313e4a92)|![tablet_main](https://github.com/user-attachments/assets/28e8b861-bf77-47b1-9f60-93267f592650)|![mobile_main](https://github.com/user-attachments/assets/fe76d76f-9b93-4403-8205-ec875e364f7f)| + +
+ +**로그인 화면** +| PC | Mobile | +| :-: | :----: | +|![pc_login](https://github.com/user-attachments/assets/aa9ff9df-8eff-4f13-a7e4-e946ad8fe48f)|![mobile_login](https://github.com/user-attachments/assets/8e1662fb-4544-48ce-a791-40e3e5ce0e28)| + +
+ +**회원가입 화면** +| PC | Mobile | +| :-: | :----: | +|![join_pc](https://github.com/user-attachments/assets/52788e32-a174-4441-abe1-957bc36094e7)|![join_mobile](https://github.com/user-attachments/assets/6222a188-d431-433c-959a-07c052ff61a5)| + +### 멘토에게 + +- 저는 CSS를 작성할 때 대부분의 단위를 `px`로 사용했는데, `rem`, `vw`와 같은 단위를 사용하는 것이 더 나은지 궁금합니다 + > 답변 + > [px vs rem vs em 접근성과 관련된 아티클](https://www.joshwcomeau.com/css/surprising-truth-about-pixels-and-accessibility/) + +
+ +## 미션 5 요구사항 + +### 기본 + +- [x] 중고마켓 페이지 주소는 "/items" 입니다. +- [x] 페이지 주소가 "/items" 일 때 상단 네비게이션 바의 "중고마켓" 버튼의 색상은 "3692FF" 입니다. +- [x] 상단 네비게이션 바는 이전 미션에서 구현한 랜딩 페이지와 동일한 스타일로 만들어주세요 +- [x] 전체 상품에서 드롭다운으로 "최신순" 또는 "좋아요순"을 선택해서 정렬할 수 있습니다. +- [ ] "상품 등록하기" 버튼을 누르면 “/additem” 로 이동합니다 ( 빈 페이지 ) +- [x] 미디어 쿼리를 사용하여 반응형 view 마다 물품 개수를 다르게 보여줍니다 (서버로 요청하는 값은 동일) +- 베스트 상품 + - Desktop : 4개 보이기 + - Tablet : 2개 보이기 + - Mobile : 1개 보이기 +- 전체 상품 + - Desktop : 10개 보이기 + - Tablet : 6개 보이기 + - Mobile : 4개 보이기 + +### 심화 + +- [x] 페이지 네이션 기능을 구현합니다. +- [x] 반응형으로 보여지는 물품들의 개수를 다르게 설정할때 서버에 보내는 pageSize값을 적절하게 설정합니다. + +### 주요 변경사항 + +- 스프린트 미션 1부터 4까지의 내용을 React로 변경했습니다. + > form 변경 내용: [React-Hook-form을 이용해 유효성 검사 해보기](https://velog.io/@nudge0613/React-Hook-Form%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%B4-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC-%ED%95%98%EA%B8%B0) + +### 스크린샷 + +| PC | Tablet | Mobile | +| :-------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------: | +| localhost_5173_items | localhost_5173_items (1) | localhost_5173_items (2) | + +
+ +## 미션 6 요구사항 + +### 기본 + +- [x] 상품 등록 페이지 주소는 “/additem” 입니다. +- [x] 페이지 주소가 “/additem” 일때 상단네비게이션바의 '중고마켓' 버튼의 색상은 “3692FF”입니다. +- [x] 상품 이미지는 최대 한개 업로드가 가능합니다. +- [x] 각 input의 placeholder 값을 정확히 입력해주세요. +- [x] 이미지를 제외하고 input 에 모든 값을 입력하면 ‘등록' 버튼이 활성화 됩니다. +- [ ] API를 통한 상품 등록은 추후 미션에서 적용합니다. + +### 심화 + +- [x] 이미지 안의 X 버튼을 누르면 이미지가 삭제됩니다. +- [x] 추가된 태그 안의 X 버튼을 누르면 해당 태그는 삭제됩니다. + +### 주요 변경사항 + +- styled component를 통해 스타일링 + +### 스크린샷 + +| PC | Tablet | Mobile | +| :---------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------: | +| localhost_5173_additem | localhost_5173_additem (1) | localhost_5173_additem (2) | + +### 멘토에게 + +- styled component를 사용해서 스타일링을 했는데 한 컴포넌트의 코드가 너무 길어지는 느낌입니다. + 스타일은 따로 빼놓는게 나을까요? + +
+ +## 미션 7 요구사항 + +### 기본 + +- [x] 상품 상세 페이지 주소는 “items/{productId}” 입니다. +- [x] response로 받은 아래의 데이터로 화면을 구현합니다. + - favoriteCount : 하트 개수 + - images : 상품 이미지 + - tags : 상품태그 + - name : 상품 이름 + - description : 상품 설명 +- [x] 목록으로 돌아가기 버튼을 클릭하면 중고마켓 페이지 주소인 "/items"로 이동합니다. +- [x] 문의하기에 내용을 입력하면 등록 버튼의 색상은 “3692FF”로 변합니다. +- [x] response 로 받은 아래의 데이터로 화면을 구현합니다. + - image : 작성자 이미지 + - nickname : 작성자 닉네임 + - content : 작성자가 남긴 문구 + - description : 상품 설명 + - updatedAt : 문의글 마지막 업데이트 시간 + +### 심화 + +- [x] 모든 버튼에 자유롭게 Hover효과를 적용하세요. + +### 주요 변경사항 + +### 스크린샷 + +| PC | Tablet | Mobile | +| :---------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------: | +| localhost_5173_additem | localhost_5173_additem (1) | localhost_5173_additem (2) | + +### 멘토에게 + +Copyright 2025 코드잇 Inc. All rights reserved. diff --git a/src/App.jsx b/src/App.jsx index 3c14e592..54629dbc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,6 +7,7 @@ import AuthLayout from "./layout/AuthLayout"; import ItemsLayout from "./layout/ItemsLayout"; import Items from "./pages/items/Items"; import AddItem from "./pages/items/AddItem"; +import ItemDetail from "./pages/items/ItemDetail"; function App() { return ( @@ -27,6 +28,7 @@ function App() { }> } /> } /> + } /> diff --git a/src/assets/icons/ic_back.svg b/src/assets/icons/ic_back.svg new file mode 100644 index 00000000..8db5377e --- /dev/null +++ b/src/assets/icons/ic_back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/ic_heart_active_large.svg b/src/assets/icons/ic_heart_active_large.svg new file mode 100644 index 00000000..aff4192d --- /dev/null +++ b/src/assets/icons/ic_heart_active_large.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/ic_heart_inactive_large.svg b/src/assets/icons/ic_heart_inactive_large.svg new file mode 100644 index 00000000..72f720d5 --- /dev/null +++ b/src/assets/icons/ic_heart_inactive_large.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/img_inquiry_empty_md.png b/src/assets/images/img_inquiry_empty_md.png new file mode 100644 index 00000000..d941b91a Binary files /dev/null and b/src/assets/images/img_inquiry_empty_md.png differ diff --git a/src/components/Inquiry.jsx b/src/components/Inquiry.jsx new file mode 100644 index 00000000..f1258fca --- /dev/null +++ b/src/components/Inquiry.jsx @@ -0,0 +1,34 @@ +import { + BackButton, + BackButtonWrapper, + InquiryTitle, +} from "../styles/components/InquiryStyles"; +import { requestInquiryLists } from "../services/inquiryApi"; +import { useNavigate } from "react-router"; +import icBack from "../assets/icons/ic_back.svg"; +import useService from "../hooks/useService"; +import InquiryWriteArea from "./InquiryWriteArea"; +import InquiryList from "../pages/components/ItemDetail/InquiryList"; + +export default function Inquiry({ id }) { + const navigate = useNavigate(); + + /** + * 문의 내역을 가져온다. + */ + const { data, isLoading } = useService(() => requestInquiryLists(id)); + + return ( + <> + 문의하기 + + {!isLoading ? :
로딩중
} + + navigate(-1)}> + 목록으로 돌아가기 + 뒤로가기 이미지 + + + + ); +} diff --git a/src/components/InquiryWriteArea.jsx b/src/components/InquiryWriteArea.jsx new file mode 100644 index 00000000..be264830 --- /dev/null +++ b/src/components/InquiryWriteArea.jsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import usePost from "../hooks/usePost"; +import { + InquirySubmitButton, + InquiryTextArea, +} from "../styles/components/InquiryStyles"; + +/** + * 문의 내역 작성 공간 + */ +export default function InquiryWriteArea() { + const [isActive, setIsActive] = useState(false); + const [inquiryContent, setInquiryContent] = useState(""); + + const onChangeWrite = (e) => { + if (e.target.value) { + setIsActive(true); + } else { + setIsActive(false); + } + + setInquiryContent(e.target.value); + }; + + const onClickUploadInquiry = () => { + let data = { + productId: id, + Inquiry: { + content: inquiryContent, + }, + }; + + const { data: success } = usePost(requestPostInquiry(data)); + + if (success) { + location.reload(true); + } else { + alert("등록에 실패했습니다."); + } + }; + + return ( +
+ + + 등록 + +
+ ); +} diff --git a/src/components/Product.jsx b/src/components/Product.jsx index be536947..467d1e1c 100644 --- a/src/components/Product.jsx +++ b/src/components/Product.jsx @@ -1,35 +1,43 @@ -import { memo } from 'react'; -import ic_heart from '../assets/icons/ic_heart.svg'; +import { memo } from "react"; +import ic_heart from "../assets/icons/ic_heart.svg"; +import { Link } from "react-router"; function Product({ products, style }) { return ( <>
{products && products.map((product) => { return ( -
- -

{product.name}

-

- {product.price - .toString() - .replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '원'} -

-
- 하트_아이콘 - - {product.favoriteCount} - + +
+ +

{product.name}

+

+ {product.price + .toString() + .replace(/\B(?=(\d{3})+(?!\d))/g, ",") + "원"} +

+
+ 하트_아이콘 + + {product.favoriteCount} + +
-
+ ); })}
diff --git a/src/components/kebab/KebabDropdown.jsx b/src/components/kebab/KebabDropdown.jsx new file mode 100644 index 00000000..1b55f55c --- /dev/null +++ b/src/components/kebab/KebabDropdown.jsx @@ -0,0 +1,30 @@ +import { memo, useRef } from "react"; +import { + KebabDropdownButton, + KebabDropdownContainer, +} from "../../styles/components/kebab/kebabStyle"; +import useCloseDropdown from "../../hooks/useCloseDropdown"; + +/** + * 케밥 메뉴 클릭 시 보여지는 드롭다운 + * @param {object[]} menus + * @param {Function} onclickClose + */ +const KebabDropdown = ({ menus, onClickClose }) => { + const dropdownRef = useRef(null); + useCloseDropdown(dropdownRef, onClickClose); + + return ( + + {menus?.map((menu) => { + return ( + + {menu.name} + + ); + })} + + ); +}; + +export default memo(KebabDropdown); diff --git a/src/components/kebab/KebabMenu.jsx b/src/components/kebab/KebabMenu.jsx new file mode 100644 index 00000000..bd01b11e --- /dev/null +++ b/src/components/kebab/KebabMenu.jsx @@ -0,0 +1,29 @@ +import useToggle from "../../hooks/useToggle"; +import { + KebabIcon, + KebabMenuContainer, +} from "../../styles/components/kebab/kebabStyle"; +import KebabDropdown from "./KebabDropdown"; + +const menus = [ + { id: "update", name: "수정하기" }, + { id: "delete", name: "삭제하기" }, +]; + +/** + * 케밥 메뉴 + */ +export default function KebabMenu() { + const { isOpen, onClickToggle, onClickClose } = useToggle(false); + + return ( + <> + + + + + {isOpen && } + + + ); +} diff --git a/src/hooks/useCloseDropdown.jsx b/src/hooks/useCloseDropdown.jsx new file mode 100644 index 00000000..7d53bf94 --- /dev/null +++ b/src/hooks/useCloseDropdown.jsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +const useCloseDropdown = (ref, callback) => { + useEffect(() => { + const onClickDropdownOutside = (event) => { + if (ref.current && !ref.current.contains(event.target)) { + callback(); + } + }; + + document.addEventListener("mousedown", onClickDropdownOutside); + + return () => { + document.removeEventListener("mousedown", onClickDropdownOutside); + }; + }, []); +}; + +export default useCloseDropdown; diff --git a/src/hooks/useMediaQuery.jsx b/src/hooks/useMediaQuery.jsx index 65806367..8196abdc 100644 --- a/src/hooks/useMediaQuery.jsx +++ b/src/hooks/useMediaQuery.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState } from "react"; const getMatches = (query) => { return window.matchMedia(query).matches; @@ -21,10 +21,10 @@ export default function useMediaQuery(query) { useEffect(() => { const media = window.matchMedia(query); - media.addEventListener('change', handleChange); + media.addEventListener("change", handleChange); return () => { - media.removeEventListener('change', handleChange); + media.removeEventListener("change", handleChange); }; }, [query]); diff --git a/src/hooks/usePost.jsx b/src/hooks/usePost.jsx new file mode 100644 index 00000000..681432e1 --- /dev/null +++ b/src/hooks/usePost.jsx @@ -0,0 +1,9 @@ +import useService from "./useService"; + +const usePost = (postFetching) => { + const { data, isLoading, isError } = useService(() => postFetching); + + return { data, isLoading, isError }; +}; + +export default usePost; diff --git a/src/hooks/useService.jsx b/src/hooks/useService.jsx new file mode 100644 index 00000000..eb137f8f --- /dev/null +++ b/src/hooks/useService.jsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; + +/** + * 데이터 통신을 위한 공통 커스텀 훅 + * @param {Function} fetchFunction 서버와 직접 통신하는 함수 + * @returns {data: object, isLoading: boolean} + */ +const useService = (fetchFunction) => { + const [isLoading, setIsLoading] = useState(false); + const [data, setData] = useState(); + const [error, setError] = useState(false); + + useEffect(() => { + const getService = async (getDataFunction) => { + try { + setIsLoading(true); + const response = await getDataFunction(); + + if (!response) { + throw new Error("서버와의 통신에 실패했습니다."); + } + + setData(response); + } catch (error) { + setError(true); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + getService(fetchFunction); + }, []); + + return { data, isLoading, error }; +}; + +export default useService; diff --git a/src/hooks/useToggle.jsx b/src/hooks/useToggle.jsx new file mode 100644 index 00000000..6fe0d5bf --- /dev/null +++ b/src/hooks/useToggle.jsx @@ -0,0 +1,22 @@ +import { useCallback, useState } from "react"; + +/** + * 토글 커스텀 훅 + * @param {boolean} initialValue 초깃값 + * @returns {isOpen: boolean, onClickToggle: Function} + */ +const useToggle = (initialValue) => { + const [isOpen, setIsOpen] = useState(initialValue); + + const onClickToggle = useCallback(() => { + setIsOpen((prevState) => !prevState); + }, [isOpen]); + + const onClickClose = useCallback(() => { + setIsOpen(false); + }, []); + + return { isOpen, onClickToggle, onClickClose }; +}; + +export default useToggle; diff --git a/src/pages/components/ItemDetail/InquiryList.jsx b/src/pages/components/ItemDetail/InquiryList.jsx new file mode 100644 index 00000000..16dfc373 --- /dev/null +++ b/src/pages/components/ItemDetail/InquiryList.jsx @@ -0,0 +1,64 @@ +import KebabMenu from "../../../components/kebab/KebabMenu"; +import { palette } from "../../../styles/commonStyles"; +import { + InquiryContent, + InquiryDate, + InquiryProfileImg, + InquiryWriter, +} from "../../../styles/components/InquiryStyles"; +import icProfile from "../../../assets/icons/ic_profile.svg"; +import imgEmptyMd from "../../../assets/images/img_inquiry_empty_md.png"; +import { formatTimeAgo } from "../../../util/formatTimeAgo"; + +const InquiryList = ({ data }) => { + return data?.list?.length > 0 ? ( + data.list?.map((el) => { + return ( +
+
+ {el.content} + +
+
+ +
+ {el.writer.nickname} + {formatTimeAgo(el.updatedAt)} +
+
+
+ ); + }) + ) : ( +
+ 빈 이미지 +

+ 아직 문의가 없어요 +

+
+ ); +}; + +export default InquiryList; diff --git a/src/pages/components/ItemDetail/ProductDetails.jsx b/src/pages/components/ItemDetail/ProductDetails.jsx new file mode 100644 index 00000000..2f71f78b --- /dev/null +++ b/src/pages/components/ItemDetail/ProductDetails.jsx @@ -0,0 +1,74 @@ +import { ItemsTag, palette, ProfileImg } from "../../../styles/commonStyles"; +import { + Heart, + HeartCount, + ProductDate, + ProductOwnerName, + ProductPrice, + ProductSubTitle, + ProductTagContainer, + ProductTagWrapper, + ProductTextArea, + ProductTextBox, + ProductTitle, +} from "../../../styles/items/ItemDetailStyle"; +import icProfile from "../../../assets/icons/ic_profile.svg"; +import icHeartInactive from "../../../assets/icons/ic_heart_inactive_large.svg"; +import icHeartActive from "../../../assets/icons/ic_heart_active_large.svg"; +import KebabMenu from "../../../components/kebab/KebabMenu"; + +export default function ProductDetails({ data, productInfo, isLoading }) { + return ( + +
+
+ {productInfo.name} + +
+ + {productInfo.price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + + "원"} + +
+
+ 상품 소개 + {productInfo.description} +
+ + 상품 태그 + + {productInfo.tags.map((tag) => { + return {`#${tag}`}; + })} + + +
+ +
+ + {!isLoading ? data?.ownerNickname : "..."} + + + {productInfo.updatedAt.slice(0, 10).replaceAll("-", ". ")} + +
+
+ + {!isLoading && data?.isFavorite ? ( + 좋아요 이미지 + ) : ( + 좋아요 이미지 + )} + {productInfo.favoriteCount} + +
+
+
+ ); +} diff --git a/src/pages/items/ItemDetail.jsx b/src/pages/items/ItemDetail.jsx new file mode 100644 index 00000000..f21deeb3 --- /dev/null +++ b/src/pages/items/ItemDetail.jsx @@ -0,0 +1,44 @@ +import { useLocation, useParams } from "react-router"; +import { requestProductDetail } from "../../services/itemsApi"; +import { + ProductDetailContainer, + ProductImg, + ProductInfoBox, +} from "../../styles/items/ItemDetailStyle"; + +import Inquiry from "../../components/Inquiry"; +import useService from "../../hooks/useService"; +import ProductDetails from "../components/ItemDetail/ProductDetails"; + +export default function ItemDetail() { + /** + * 상품의 id + */ + const { productId } = useParams(); + + /** + * 목록에서 가져온 상품 정보 + */ + const { state: productInfo } = useLocation(); + + /** + * 상품 정보 받아오기 + */ + const { data, isLoading } = useService(() => requestProductDetail(productId)); + + return ( + <> + + + + + + + + + ); +} diff --git a/src/services/inquiryApi.js b/src/services/inquiryApi.js new file mode 100644 index 00000000..0e05488b --- /dev/null +++ b/src/services/inquiryApi.js @@ -0,0 +1,48 @@ +/** + * 문의 데이터를 가져온다. + * @param {string} productId + */ +export const requestInquiryLists = async (productId) => { + const url = new URL( + `https://panda-market-api.vercel.app/products/${productId}/comments` + ); + url.searchParams.append("limit", 3); + + const response = await fetch(url, { + method: "get", + headers: { + "Content-Type": "application/json", + }, + }); + + return response.json(); +}; + +/** + * 문의 내용을 등록한다. + * @param {object{}} inquiryData 상품 아이디와 문의 내역 객체 + * @returns + */ +export const requestPostInquiry = async (inquiryData) => { + const url = new URL( + `https://panda-market-api.vercel.app/products/${inquiryData.productId}/comments` + ); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(inquiryData.inquiry), + }); + + if (!response.ok) { + alert("데이터를 전송하지 못했습니다. 다시 시도해주세요"); + } + + return response.json(); + } catch (error) { + console.error(error); + } +}; diff --git a/src/services/itemsApi.js b/src/services/itemsApi.js index 50de8d16..8148c414 100644 --- a/src/services/itemsApi.js +++ b/src/services/itemsApi.js @@ -3,20 +3,38 @@ * @param {string} orderBy * @returns {object} */ -const requestProductList = async (querys) => { - const url = new URL('https://panda-market-api.vercel.app/products'); - url.searchParams.append('page', querys.page); - url.searchParams.append('pageSize', querys.pageSize); - url.searchParams.append('orderBy', querys.orderBy); +export const requestProductList = async (query) => { + const url = new URL("https://panda-market-api.vercel.app/products"); + url.searchParams.append("page", query.page); + url.searchParams.append("pageSize", query.pageSize); + url.searchParams.append("orderBy", query.orderBy); const response = await fetch(url, { - method: 'get', + method: "get", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, }); return response.json(); }; -export { requestProductList }; +/** + * 상품의 상세 정보를 요청한다. + * @param {string} productId + * @returns {json} response + */ +export const requestProductDetail = async (productId) => { + const url = new URL( + `https://panda-market-api.vercel.app/products/${productId}` + ); + + const response = await fetch(url, { + method: "get", + headers: { + "Content-Type": "application/json", + }, + }); + + return response.json(); +}; diff --git a/src/styles/commonStyles.js b/src/styles/commonStyles.js index bb58912d..ce33a4e4 100644 --- a/src/styles/commonStyles.js +++ b/src/styles/commonStyles.js @@ -1,6 +1,9 @@ import styled from "styled-components"; export const palette = { + secondary: { + gray500: "#6B7280", + }, gray900: "#111827", gray800: "#1f2937", gray700: "#374151", @@ -10,6 +13,7 @@ export const palette = { gray200: "#e5e7eb", gray100: "#f3f4f6", gray50: "#f9fafb", + coolGray300: "#D1D5DB", blue: "#3692ff", menu: "#4b5563", }; @@ -30,3 +34,8 @@ export const ItemsTag = styled.div` padding: 15px; margin-right: 10px; `; + +export const ProfileImg = styled.img` + width: 40px; + height: 40px; +`; diff --git a/src/styles/components/InquiryStyles.js b/src/styles/components/InquiryStyles.js new file mode 100644 index 00000000..cac63e29 --- /dev/null +++ b/src/styles/components/InquiryStyles.js @@ -0,0 +1,112 @@ +import styled from "styled-components"; +import { palette, ProfileImg } from "../commonStyles"; + +export const InquiryTextArea = styled.textarea` + background-color: ${palette.gray100}; + height: 104px; + width: 100%; + border: none; + border-radius: 12px; + resize: none; + font-family: Pretendard-Reqular; + padding: 20px; + font-size: 14px; + font-weight: 400; + margin-bottom: 10px; + &::placeholder { + color: ${palette.gray400}; + } + + @media (min-width: 744px) { + font-size: 16px; + line-height: 26px; + letter-spacing: 0%; + } +`; + +export const InquiryTitle = styled.p` + color: ${palette.gray900}; + font-weight: 600; + font-size: 16px; + margin: 40px 0px 10px; + text-align: left; +`; + +export const InquirySubmitButton = styled.button` + width: 74px; + height: 42px; + border: none; + color: white; + padding: 10px; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + background-color: ${({ isActive }) => + isActive ? `${palette.blue}` : `${palette.gray400}`}; + + &:hover { + background-color: ${({ isActive }) => + isActive ? `#1967d6` : `${palette.gray400}`}; + } + + &:active { + background-color: ${({ isActive }) => + isActive ? `#1251aa` : `${palette.gray400}`}; + } +`; + +export const InquiryContent = styled.p` + margin: 0; + color: ${palette.gray800}; + font-size: 14px; + font-weight: 400; +`; + +export const InquiryProfileImg = styled(ProfileImg)` + width: 32px; + height: 32px; +`; + +export const InquiryWriter = styled.p` + margin: 0px 0px 5px; + font-size: 12px; + font-weight: 400; +`; + +export const InquiryDate = styled.p` + margin: 0; + font-size: 12px; + font-weight: 400; + color: ${palette.gray400}; +`; + +export const BackButtonWrapper = styled.div` + margin: 60px 0px 60px; + text-align: center; + display: flex; + justify-content: center; +`; + +export const BackButton = styled.button` + width: 240px; + height: 48px; + border: none; + background-color: ${palette.blue}; + color: white; + padding: 10px; + border-radius: 40px; + font-size: 18px; + font-weight: 600; + justify-content: center; + align-items: center; + display: flex; + gap: 10px; + + &:hover { + background-color: #1967d6; + } + + &:active { + background-color: #1251aa; + } +`; diff --git a/src/styles/components/kebab/kebabStyle.js b/src/styles/components/kebab/kebabStyle.js new file mode 100644 index 00000000..37c9d153 --- /dev/null +++ b/src/styles/components/kebab/kebabStyle.js @@ -0,0 +1,57 @@ +import styled from "styled-components"; +import { palette } from "../../commonStyles"; + +export const KebabMenuContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 2px; + width: 24px; + height: 24px; + cursor: pointer; + position: relative; +`; + +export const KebabIcon = styled.div` + width: 3px; + height: 3px; + background-color: ${palette.gray400}; + border-radius: 99px; +`; + +export const KebabDropdownContainer = styled.div` + width: 139px; + display: flex; + flex-direction: column; + position: absolute; + top: 100%; + right: 7%; + margin-top: 5px; + border: 1px solid ${palette.coolGray300}; + border-radius: 8px; + z-index: 50; + + & > :first-child { + border-top-right-radius: 8px; + border-top-left-radius: 8px; + } + + & > :last-child { + border-bottom-right-radius: 8px; + border-bottom-left-radius: 8px; + } +`; + +export const KebabDropdownButton = styled.button` + background-color: white; + border: none; + font-weight: 400; + font-size: 16px; + color: ${palette.secondary.gray500}; + padding: 16px 0px 12px; + + &:hover { + background-color: ${palette.gray100}; + } +`; diff --git a/src/styles/items/ItemDetailStyle.js b/src/styles/items/ItemDetailStyle.js new file mode 100644 index 00000000..e20d3ecc --- /dev/null +++ b/src/styles/items/ItemDetailStyle.js @@ -0,0 +1,181 @@ +import styled from "styled-components"; +import { ItemsTag, palette } from "../commonStyles"; + +export const ProductDetailContainer = styled.div` + width: 100%; + margin: 0 auto; + max-width: 1200px; + padding: 16px 16px 0px 16px; + + @media (min-width: 744px) { + padding: 24px 24px 0px 24px; + } +`; + +export const ProductInfoBox = styled.div` + display: flex; + margin-top: 20px; + border-bottom: 1px solid ${palette.gray200}; + padding-bottom: 40px; + flex-direction: column; + + @media (min-width: 744px) { + flex-direction: row; + } +`; + +export const ProductTextBox = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +export const ProductImg = styled.img` + border-radius: 16px; + border: none; + + @media (min-width: 375px) { + width: 343px; + height: 343px; + margin: 0; + margin-bottom: 16px; + } + + @media (min-width: 744px) { + margin-right: 16px; + } + + @media (min-width: 1200px) { + margin-right: 20px; + width: 486px; + height: 486px; + } +`; + +export const ProductTitle = styled.h1` + font-weight: 600; + margin: 0; + + @media (min-width: 375px) { + font-size: 16px; + line-height: 26px; + } + + @media (min-width: 744px) { + font-size: 20px; + line-height: 32px; + letter-spacing: 0%; + } + + @media (min-width: 1200px) { + font-size: 24px; + line-height: 32px; + letter-spacing: 0%; + } +`; + +export const ProductPrice = styled.p` + font-weight: 600; + + @media (min-width: 375px) { + font-size: 24px; + line-height: 32px; + margin: 8px 0px 16px; + } + + @media (min-width: 744px) { + font-size: 32px; + line-height: 42px; + letter-spacing: 0%; + } + + @media (min-width: 1200px) { + font-size: 40px; + line-height: 32px; + letter-spacing: 0%; + margin: 20px 0px 16px; + } +`; + +export const ProductSubTitle = styled.p` + color: ${palette.gray600}; + + @media (min-width: 375px) { + font-size: 14px; + line-height: 24px; + letter-spacing: 0%; + margin: 16px 0px 8px; + } + + @media (min-width: 1200px) { + font-size: 16px; + font-weight: 600; + margin: 20px 0px; + } +`; + +export const ProductTextArea = styled.p` + color: ${palette.gray600}; + width: 100%; + margin: 0; + + @media (min-width: 375px) { + font-size: 16px; + line-height: 26px; + letter-spacing: 0%; + } +`; + +export const ProductTagWrapper = styled.div` + @media (min-width: 375px) { + margin-bottom: 40px; + } + + @media (min-width: 1200px) { + margin-bottom: 100px; + } +`; + +export const ProductTagContainer = styled.div` + display: flex; + flex-wrap: wrap; + row-gap: 10px; +`; + +export const ProductOwnerName = styled.p` + font-size: 14px; + font-weight: 500; + margin: 0px 0px 10px 0px; + color: ${palette.gray600}; +`; + +export const ProductDate = styled.p` + font-size: 14px; + font-weight: 400; + color: ${palette.gray400}; + margin: 0; +`; + +export const Heart = styled.div` + border: 1px solid ${palette.gray200}; + border-radius: 35px; + background-color: white; + display: flex; + justify-content: space-between; + column-gap: 5px; + padding: 2px 10px; + align-items: center; + transition: transform 0.3s ease; + + &:hover { + cursor: pointer; + transform: scale(1.1); + } +`; + +export const HeartCount = styled.p` + font-size: 16px; + font-weight: 500; + color: ${palette.gray500}; + margin: 0; +`; diff --git a/src/util/formatTimeAgo.js b/src/util/formatTimeAgo.js new file mode 100644 index 00000000..71c60d4d --- /dev/null +++ b/src/util/formatTimeAgo.js @@ -0,0 +1,37 @@ +/** + * DateTime을 방금전, n분, 일, 월, 년전 형태로 출력한다. + * @param {string} dateString + * @returns {string} + */ +export const formatTimeAgo = (dateString) => { + const now = new Date(); + const past = new Date(dateString); + const diffInMs = now.getTime() - past.getTime(); + + // 시간 단위(밀리초) + const msInSecond = 1000; + const msInMinute = msInSecond * 60; + const msInHour = msInMinute * 60; + const msInDay = msInHour * 24; + const msInMonth = msInDay * 30; // 간단한 계산을 위해 30일로 가정 + const msInYear = msInDay * 365; // 간단한 계산을 위해 365일로 가정 + + if (diffInMs >= msInYear) { + const years = Math.floor(diffInMs / msInYear); + return `${years}년 전`; + } else if (diffInMs >= msInMonth) { + const months = Math.floor(diffInMs / msInMonth); + return `${months}달 전`; + } else if (diffInMs >= msInDay) { + const days = Math.floor(diffInMs / msInDay); + return `${days}일 전`; + } else if (diffInMs >= msInHour) { + const hours = Math.floor(diffInMs / msInHour); + return `${hours}시간 전`; + } else if (diffInMs >= msInMinute) { + const minutes = Math.floor(diffInMs / msInMinute); + return `${minutes}분 전`; + } else { + return "방금 전"; + } +};