Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
33dbfa1
docs: 요구사항 목록 정리
MyungJiwoo May 11, 2025
107698b
feat: 상품 목록에서 상품별 상세페이지로 라우터 연결
MyungJiwoo May 11, 2025
875a173
fix: 전역 폰트 스타일 설정
MyungJiwoo May 11, 2025
ad8b2ea
chore: 필요한 이미지 및 아이콘 추가
MyungJiwoo May 11, 2025
d08aa19
fix: 반응형에 따른 navbar 레이아웃 여백 수정
MyungJiwoo May 11, 2025
b91ce19
feat: textarea의 행간 설정 및 style type prop 추가
MyungJiwoo May 11, 2025
7cc0522
feat: 상품 상세 페이지 기본 구조 및 스타일
MyungJiwoo May 11, 2025
09a16d9
api: 상품 상세, 상품 댓글 불러오는 api 설정
MyungJiwoo May 11, 2025
c8075f6
feat: 문의하기 등록 버튼 유효성 검사의 useFormValidation 훅 추가
MyungJiwoo May 11, 2025
3badf19
feat: 날짜 포맷팅 util 함수 추가
MyungJiwoo May 11, 2025
f866ebe
refactor: 상세 태그를 별도의 컴포넌트로 분리
MyungJiwoo May 11, 2025
25a6d47
refactor: 상품 소개, 상품 태그의 정보를 공통 레이아웃으로 분리
MyungJiwoo May 11, 2025
b7ae85f
feat: 상품 상세 정보, 상품 댓글 조회 api 연동
MyungJiwoo May 11, 2025
fc5a67e
feat: 문의하기 입력폼 유효성 검사 후 등록 버튼 활성화
MyungJiwoo May 11, 2025
126415b
docs: 진행 상황 업데이트
MyungJiwoo May 11, 2025
08c4591
fix: 모바일 반응형 breakpoint 수정
MyungJiwoo May 11, 2025
7f475f4
refactor: Inquiry를 Comment로 네이밍 변경
MyungJiwoo May 11, 2025
cb98059
refactor: 드롭다운 메뉴를 공통 컴포넌트로 분리
MyungJiwoo May 14, 2025
b24c7a2
refactor: 작성자 프로필 + 시간 정보를 WriterInfo 컴포넌트로 분리
MyungJiwoo May 14, 2025
da173eb
refactor: TextButton 공통 컴포넌트 추가 및 사용
MyungJiwoo May 14, 2025
4113bbb
refactor: 공통 컴포넌트를 components/common로 이동
MyungJiwoo May 14, 2025
ff54bb6
refactor: products-detail-page/components에서 sections 디렉토리 분리
MyungJiwoo May 14, 2025
f2c7f0e
refactor: useObserver 훅 분리
MyungJiwoo May 14, 2025
e77114e
refactor: 오른쪽에 아이콘이 있는 버튼을 공통 컴포넌트인 RightIconButton으로 분리 후 사용
MyungJiwoo May 15, 2025
2355225
refactor: 핸들러 로직을 커스텀 훅으로 분리
MyungJiwoo May 15, 2025
ea8d576
refactor: 좋아요 개수 버튼을 컴포넌트로 분리 & 과도한 추상화 제거
MyungJiwoo May 15, 2025
5da7345
refactor: 네이밍 수정 & 불필요한 구조(div) 삭제 & 인라인 이벤트 핸들러를 함수로 분리
MyungJiwoo May 15, 2025
f23008f
docs: 요구사항 목록에 누락된 진행 상황 업데이트
MyungJiwoo May 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 10 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## 판다마켓 6
## 판다마켓 7

**🌐 배포 url: https://myungjiwoo-pandamarket.netlify.app/additem**
**🌐 배포 url: https://myungjiwoo-pandamarket.netlify.app/items**

### 기본 요구사항

Expand All @@ -10,24 +10,14 @@

### 체크 리스트 (기본)

- [x] 상품 등록 페이지 주소는 "/additem"이다.
- [x] 페이지 주소가 "/additem"일 때 상단 네비게이션바의 "중고마켓" 버튼의 색상은 "3692FF"이다.
- [x] 상품 이미지는 최대 한 개까지 업로드할 수 있다.
- [x] 각 input의 placeholder 값을 정확히 입력한다.
- [x] 이미지를 제외하고 input에 모든 값을 입력하면 '등록' 버튼이 활성화 된다. (api를 통한 상품 등록은 추후 미션에서 적용)
- [x] 상품 상세 페이지 주소는 “/items/{productId}” 이다.
- [x] 상세 정보 : response 로 받은 아래의 데이터로 화면을 구현한다. (favoriteCount, images, tags, name, description)
- [x] 목록으로 돌아가기 버튼을 클릭하면 중고마켓 페이지 주소인 “/items” 으로 이동한다.
- [x] 문의하기에 내용을 입력하면 등록 버튼의 색상은 “3692FF”로 변경된다.
- [x] 문의 : response 로 받은 아래의 데이터로 화면을 구현한다. (image, nickname, content, description, updatedAt)
- [x] 문의를 수정하려면 기존 문의글이 input으로 바뀐다.
- [x] 아무 문의가 없을때는 적절한 안내 문구를 띄워준다.

### 체크 리스트 (심화)

- [x] 이미지 안의 x 버튼을 누르면 이미지가 삭제된다.
- [x] 추가된 태그 안의 x 버튼을 누르면 해당 태그는 삭제된다.

### 추가 기능

- [x] 오류 메시지를 토스트 메시지로 구현 (react-toastify 라이브러리 사용)

### 구현 포인트

- [x] 입력 컴포넌트 계층화 및 재사용
- `Base~ 컴포넌트` : 최소 단위 입력 컴포넌트
- `~Field 컴포넌트` : 공통 인터페이스를 추가한 확장 컴포넌트 (label, error messge 등)
- `Item~Field 컴포넌트` : 도메인 전용 컴포넌트
- [ ] 모든 버튼에 자유롭게 Hover 효과를 적용한다.
1 change: 1 addition & 0 deletions src/GlobalStyle.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const baseStyle = css`
padding: 0;
box-sizing: border-box;
transition: all 100ms ease-in-out;
font-family: "Pretendard", sans-serif;
}

html,
Expand Down
26 changes: 26 additions & 0 deletions src/apis/productApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { instance } from "@apis/instance";

const getProduct = async (productId) => {
try {
const { data } = await instance.get(`/products/${productId}`);
return data;
} catch (error) {
throw new Error(`상품 상세 조회 불러오기 실패: ${error.message}`);
}
};

const getProductComments = async (productId, limit = 10, cursor = null) => {
try {
let url = `/products/${productId}/comments?limit=${limit}`;
if (cursor) {
url += `&cursor=${cursor}`;
}
Comment on lines +14 to +17
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안
getProductComments에서 쿼리 문자열을 직접 조합하기보다는 URLSearchParams을 활용하시면 가독성과 유지보수성이 더 좋을 것 같아요~

https://developer.mozilla.org/ko/docs/Web/API/URLSearchParams


const { data } = await instance.get(url);
return data;
} catch (error) {
throw new Error(`상품의 댓글 정보 불러오기 실패: ${error.message}`);
}
};

export { getProduct, getProductComments };
19 changes: 19 additions & 0 deletions src/assets/icons/back.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const BackIcon = () => {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.53333 3.60012C6.03627 3.60012 5.63333 4.00307 5.63333 4.50012C5.63333 4.99718 6.03627 5.40012 6.53333 5.40012V3.60012ZM6.53333 5.40012H16.6667V3.60012H6.53333V5.40012ZM21.1 9.83345V10.9001H22.9V9.83345H21.1ZM16.6667 15.3335H6.53333V17.1335H16.6667V15.3335ZM21.1 10.9001C21.1 13.3486 19.1151 15.3335 16.6667 15.3335V17.1335C20.1092 17.1335 22.9 14.3427 22.9 10.9001H21.1ZM16.6667 5.40012C19.1151 5.40012 21.1 7.38499 21.1 9.83345H22.9C22.9 6.39088 20.1092 3.60012 16.6667 3.60012V5.40012Z"
fill="white"
/>
<path d="M3 16.2335L10.2 12.5384L10.2 19.9285L3 16.2335Z" fill="white" />
</svg>
);
};

export default BackIcon;
17 changes: 17 additions & 0 deletions src/assets/icons/more.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const MoreIcon = () => {
return (
<svg
width="3"
height="13"
viewBox="0 0 3 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="1.5" cy="1.50012" r="1.5" fill="#9CA3AF" />
<circle cx="1.5" cy="6.50012" r="1.5" fill="#9CA3AF" />
<circle cx="1.5" cy="11.5001" r="1.5" fill="#9CA3AF" />
</svg>
);
};

export default MoreIcon;
Binary file added src/assets/imgs/CommentEmpty@2x.png
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안
파일명이 다른 파일명과 같은 네이밍 룰에 따라 명명되면 좋겠네요~

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 89 additions & 0 deletions src/components/DropdownMenu.jsx
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안
메뉴 외의 영역을 눌렀을때도 닫히게 해주시면 더 사용성이 좋아질 것 같아요.

Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useState } from "react";
import styled from "@emotion/styled";
import BaseButton from "@/components/common/BaseButton";
import MoreIcon from "@assets/icons/more";

const DROPDOWN_LIST_POSITION = {
left: "right: 0; left: auto;",
right: "left: 0; right: auto;",
};

const DropdownMenu = ({
dropdownItem1,
onDropdownItem1Click,
dropdownItem2,
onDropdownItem2Click,
position = "left",
}) => {
Comment on lines +11 to +17
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안
지금 컴포넌트 구조상 메뉴가 2개인 경우만 사용이 가능합니다~
공용 컴포넌트인만큼 여러개인 경우도 커버할 수 있게 수정해보시면 더 좋을 것 같아요!

const [isOpen, setIsOpen] = useState(false);

const handleToggle = () => setIsOpen((prev) => !prev);
Comment on lines +18 to +20
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 여담
reducer 를 사용해 아래처럼 간단하게도 작성 가능합니다~

Suggested change
const [isOpen, setIsOpen] = useState(false);
const handleToggle = () => setIsOpen((prev) => !prev);
const [isOpen, toggleIsOpen] = useReducer((prev) => !prev, false);


const handleDropdownItem1Click = () => {
onDropdownItem1Click();
setIsOpen(false);
};

const handleDropdownItem2Click = () => {
onDropdownItem2Click();
setIsOpen(false);
};

return (
<DropdownMenuContainer>
<StyledButton onClick={handleToggle}>
<MoreIcon />
</StyledButton>

{isOpen && (
<DropdownList listPosition={DROPDOWN_LIST_POSITION[position]}>
<DropdownItem onClick={handleDropdownItem1Click}>
{dropdownItem1}
</DropdownItem>
<DropdownItem onClick={handleDropdownItem2Click}>
{dropdownItem2}
</DropdownItem>
</DropdownList>
)}
</DropdownMenuContainer>
);
};

export default DropdownMenu;

const DropdownMenuContainer = styled.div`
position: relative;
`;

const StyledButton = styled(BaseButton)`
width: 2.4rem;
height: 2.4rem;
`;

const DropdownList = styled.ul`
width: fit-content;
position: absolute;
top: 3rem;
${(props) => props.listPosition};
border-radius: 0.7rem;
border: 1px solid var(--gray200);
background-color: var(--white);
`;

const DropdownItem = styled.li`
width: 10rem;
padding: 0.8rem 1rem;
text-align: center;
list-style: none;
font-size: 1.4rem;
color: var(--gray500);
cursor: pointer;

&:hover {
background-color: var(--gray100);
}

&:first-of-type {
border-bottom: 1px solid var(--gray200);
}
`;
2 changes: 1 addition & 1 deletion src/components/ImageInputField.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { memo } from "react";
import styled from "@emotion/styled";
import BaseImageInput from "@components/BaseImageInput";
import BaseImageInput from "@/components/common/BaseImageInput";
import DeleteButton from "@components/DeleteButton";
import PlusIcon from "@assets/icons/plus";

Expand Down
2 changes: 1 addition & 1 deletion src/components/InputField.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { memo } from "react";
import styled from "@emotion/styled";
import BaseInput from "@components/BaseInput";
import BaseInput from "@/components/common/BaseInput";

const InputField = ({ id, label, value, onChange, errorMessage, ...props }) => {
return (
Expand Down
17 changes: 10 additions & 7 deletions src/components/ProductCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useContext, createContext, useRef } from "react";
import styled from "@emotion/styled";
import HeartIcon from "@assets/icons/heart";
import NotFoundImg from "@assets/imgs/notFoundImage@2x.png";
import { Link } from "react-router-dom";

const ProductContext = createContext({
id: null,
Expand All @@ -22,13 +23,15 @@ const ProductCard = ({ id, src, title, price = 0, like = 0, children }) => {

return (
<ProductContext.Provider value={contextValue}>
<ProductCardLayout>
<ProductImg />
<ProductTitle />
<ProductPrice />
<ProductLike />
{children}
</ProductCardLayout>
<Link to={`/items/${id}`}>
<ProductCardLayout>
<ProductImg />
<ProductTitle />
<ProductPrice />
<ProductLike />
{children}
</ProductCardLayout>
</Link>
</ProductContext.Provider>
);
};
Expand Down
47 changes: 47 additions & 0 deletions src/components/TextButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import styled from "@emotion/styled";
import BaseButton from "@/components/common/BaseButton";

const BUTTON_SIZE = {
s: "1.2rem",
m: "1.4rem",
l: "1.6rem",
};

const BUTTON_TYPE = {
primary: "background-color: var(--blue); color: var(--white);",
cancel: "background-color: var(--white); var(--gray500);",
danger: "background-color: red; color: var(--white);",
};

const TextButton = ({
text,
onClick,
disabled = false,
size = "m",
type = "primary",
}) => {
return (
<StyledButton
onClick={onClick}
disabled={disabled}
size={BUTTON_SIZE[size]}
type={BUTTON_TYPE[type]}
>
{text}
</StyledButton>
);
};

export default TextButton;

const StyledButton = styled(BaseButton)`
padding: 0.8rem 2rem;
border-radius: 1rem;
font-size: ${(props) => props.size};
${(props) => props.type};

&:disabled {
background-color: var(--gray300);
cursor: not-allowed;
}
`;
35 changes: 32 additions & 3 deletions src/components/TextareaField.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { memo } from "react";
import styled from "@emotion/styled";
import BaseTextarea from "@components/BaseTextarea";
import BaseTextarea from "@/components/common/BaseTextarea";

// styleType : default | addItem
const TextareaField = ({
id,
label,
value,
onChange,
errorMessage,
styleType = "default",
...props
}) => {
const { InputSection, Label } =
textareaStyleMap[styleType] || textareaStyleMap.default;

return (
<InputSection>
<Label htmlFor={id}>{label}</Label>
Expand All @@ -21,11 +26,15 @@ const TextareaField = ({

export default memo(TextareaField);

const InputSection = styled.div`
const LargeInputSection = styled.div`
margin: 2rem 0;
`;

const Label = styled.label`
const InputSection = styled.div`
margin: 1rem 0;
`;

const BoldLabel = styled.label`
display: inline-block;
margin-bottom: 1rem;
font-weight: bold;
Expand All @@ -34,7 +43,27 @@ const Label = styled.label`
font-size: 1.8rem;
`;

const Label = styled.label`
display: inline-block;
margin-bottom: 1rem;
line-height: 2.6rem;
color: var(--gray900);
font-size: 1.6rem;
`;

const ErrorMessage = styled.p`
color: red;
font-size: 1.4rem;
`;

// styleType에 따른 스타일 매핑
const textareaStyleMap = {
default: {
InputSection: InputSection,
Label: Label,
},
addItem: {
InputSection: LargeInputSection,
Label: BoldLabel,
},
};
24 changes: 24 additions & 0 deletions src/components/common/BaseButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import styled from "@emotion/styled";

const BaseButton = ({ onClick, children, ...props }) => {
return (
<Button onClick={onClick} {...props}>
{children}
</Button>
);
};

export default BaseButton;

const Button = styled.button`
border: none;
background: none;

display: inline-flex;
align-items: center;
justify-content: center;

&:hover {
opacity: 0.7;
}
`;
File renamed without changes.
File renamed without changes.
Loading