Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
Binary file added public/images/common/Img_inquiry_empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions public/images/common/ic_back.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/images/common/ic_heart_detail.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions public/images/common/ic_kebab.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file removed src/App.css
Empty file.
29 changes: 3 additions & 26 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,10 @@
import './App.css';
import './styles/reset.css';
import './styles/layout.css';
import './styles/common.css';
import { useState, useReducer, useRef, createContext } from 'react';
import { Routes, Route } from 'react-router-dom';
import Login from './pages/Login';
import Signup from './pages/Signup';
import Items from './pages/Items';
import Board from './pages/Board';
import Notfound from './pages/Notfound';
import AddItem from './pages/AddItem';
import Home from './pages/Home';

const AuthStateContext = createContext();
const AuthDispatchContext = createContext();
export { AuthStateContext, AuthDispatchContext };
import './styles/index.css';
import AppRoutes from './routes/AppRoutes';

function App() {
return (
<>
<Routes>
<Route path='/' element={<Home />} />
<Route path='/login' element={<Login />} />
<Route path='/signup' element={<Signup />} />
<Route path='/items' element={<Items />} />
<Route path='/additem' element={<AddItem />} />
<Route path='/board' element={<Board />} />
<Route path='*' element={<Notfound />} />
</Routes>
<AppRoutes />
Copy link
Collaborator

Choose a reason for hiding this comment

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

💊 제안
이렇게 컴포넌트로 분리하신 이유를 모르겠어요.
기존의 코드들을 하나의 컴포넌트로 단순히 분리만 한거라 구조를 파악하고 싶을때 하나의 파일을 더 봐야해서 가독성에도 좋지 않은 것 같아요.
만약 route 파일을 따로 빼고 싶으시다면 지금은 react-router-dom 라이브러리의 declative 모드로 작업하고 계시는데 Data 모드나 Framework 모드를 사용하시는 것이 더 적절할 것 같아요~

</>
);
}
Expand Down
21 changes: 21 additions & 0 deletions src/api/productCommentAPI.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { baseAPI } from './axios';

export const productCommentAPI = {
// 상품 댓글 조회
getProductComments: async (productId, limit, cursor) => {
Comment on lines +3 to +5
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

Copy link
Collaborator

Choose a reason for hiding this comment

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

💊 제안
productCommentAPI 객체명과 getProductComments 메소드명이 모두 “product”와 “comments”를 포함해 불필요하게 중복되는 것 같아요. 이름에 대해 고민해보시면 좋겠어요~

try {
// cursor가 null이면 limit만 전달
const queryParams = cursor
? `limit=${limit}&cursor=${cursor}`
: `limit=${limit}`;

const response = await baseAPI.get(
`/products/${productId}/comments?${queryParams}`
);
return response.data;
} catch (error) {
throw new Error('댓글을 불러오는데 실패했습니다.');
}
},

};
21 changes: 21 additions & 0 deletions src/components/Detail/CommentItemMenu.jsx
Copy link
Collaborator

Choose a reason for hiding this comment

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

💬 여담
디자인대로 작업하시고 focus된 것을 알 수 있게 해주신 점 좋아요!
다만 CSS의 :focus 가상 선택자는 마우스로 클릭할 때도 outline을 표시하기 때문에, 마우스 탐색 시에는 불필요하게 보일 수 있어요. 키보드 네비게이션에서만 포커스 스타일이 적용되길 원하신다면 :focus-visible을 사용해 보세요!

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,21 @@
import styles from './styles/CommentItemMenu.module.css';

export default function CommentItemMenu({ isOpen, onClick }) {
return (
<div className={styles.commentItemMenu}>
<button type='button' onClick={onClick}>
<img src='/images/common/ic_kebab.svg' alt='메뉴 열기' />
</button>
{isOpen && (
<ul>
<li>
<button type='button'>수정하기</button>
</li>
<li>
<button type='button'>삭제하기</button>
</li>
</ul>
)}
</div>
);
};
19 changes: 19 additions & 0 deletions src/components/Detail/DetailBackToListButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useLocation } from 'react-router-dom';
import styles from './styles/DetailBackToListButton.module.css';
import { useNavigate } from 'react-router-dom';
Comment on lines +1 to +3
Copy link
Collaborator

Choose a reason for hiding this comment

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

💊 제안

Suggested change
import { useLocation } from 'react-router-dom';
import styles from './styles/DetailBackToListButton.module.css';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import styles from './styles/DetailBackToListButton.module.css';


export default function DetailBackToListButton() {
const navigate = useNavigate();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
Comment on lines +6 to +8
Copy link
Collaborator

Choose a reason for hiding this comment

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

💊 제안
search 값만 필요하니 아래처럼 하시는 것이 더 좋을 것 같아요.
또한 선언만 되고 사용되지 않는 searchParams 변수는 제거하시는 것이 좋습니다~

Suggested change
const navigate = useNavigate();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const navigate = useNavigate();
const { search } = useLocation();


const handleBackToList = () => {
const prevQuery = location.state?.prevQuery || '';
navigate(`/items${prevQuery}`);
};
return (
<button className={styles.backToList} onClick={handleBackToList}>
목록으로 돌아가기 <img src='/images/common/ic_back.svg' alt='목록으로 돌아가기' />
</button>
);
}
43 changes: 43 additions & 0 deletions src/components/Detail/DetailComment.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import styles from './styles/DetailComment.module.css';
import { useProductComment } from '@/hooks/useProductComment';
import { relativeTime } from '@/utils/relativeTimeUtils';
import DetailProfile from '@/components/Detail/DetailProfile';
import CommentItemMenu from '@/components/Detail/CommentItemMenu';
import { useState } from 'react';

export default function DetailComment({ productId }) {
const { productComments, loading, error } = useProductComment(productId, 3, null);
const [openMenuId, setOpenMenuId] = useState(null);

if (loading) return <div>로딩 중...</div>;
if (error) return <div className={styles.error}>{error}</div>;
if (!productComments?.list?.length) return (
<div className={styles.noComments}>
<img src='/images/common/img_inquiry_empty.png' alt='댓글이 없습니다.' />
<p>아직 문의가 없어요</p>
</div>
);

const onClickMenu = (commentId) => {
setOpenMenuId(openMenuId === commentId ? null : commentId);
};

return (
<ul className={styles.detailComment}>
{productComments.list.map((comment) => (
<li key={comment.id} className={styles.commentItem}>
<div className={styles.commentItemDesc}>
<p className={styles.commentContent}>{comment.content}</p>
<CommentItemMenu isOpen={openMenuId === comment.id} onClick={() => onClickMenu(comment.id)} />
</div>
<DetailProfile
profileImage={comment.writer.image}
profileNickname={comment.writer.nickname}
profileUpdate={relativeTime(comment.updatedAt)}
isComment
/>
</li>
))}
</ul>
);
}
24 changes: 24 additions & 0 deletions src/components/Detail/DetailInquiry.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import styles from './styles/DetailInquiry.module.css';
import CommonButton from '@/components/Common/CommonButton';
import { useState } from 'react';

export default function DetailInquiry({ label }) {
const [inquiry, setInquiry] = useState('');

return (
<form className={styles.detailInquiry}>
<label htmlFor='inquiry'>{label}</label>
<textarea
id='inquiry'
placeholder='개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다.'
value={inquiry}
onChange={(e) => setInquiry(e.target.value)}
/>
<InquiryButton disabled={inquiry.length === 0} />
</form>
);
}

const InquiryButton = ({ disabled }) => {
return <CommonButton buttonType={{ buttonType: 'submit', buttonStyle: 'primary', buttonText: '등록' }} disabled={disabled} />;
};
22 changes: 22 additions & 0 deletions src/components/Detail/DetailProfile.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import styles from './styles/DetailProfile.module.css';

export default function DetailProfile({ profileImage, profileNickname, profileUpdate, isComment=false}) {
return (
<div className={`${styles.profile} ${isComment ? styles.isComment : ''}`}>
<div className={styles.profileImage}>
<img
src={profileImage || '/images/common/ic_log.svg'}
alt='프로필 이미지'
onError={(e) => {
e.target.onerror = null;
e.target.src = '/images/common/ic_log.svg';
}}
/>
</div>
<div>
<p className={styles.profileNickName}>{profileNickname}</p>
<p className={styles.profileUpdate}>{profileUpdate}</p>
</div>
</div>
);
}
67 changes: 67 additions & 0 deletions src/components/Detail/styles/CommentItemMenu.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
.commentItemMenu {
position: relative;
> button {
border-radius: 50%;
transition: background-color 0.2s ease;
outline: none;
&:hover {
background-color: var(--gray100);
}
}
ul {
position: absolute;
right: 0;
bottom: -10px;
transform: translateY(100%);
width: 140px;
border: 1px solid #d1d5db;
border-radius: 8px;
padding-block: 4px;
background-color: #fff;
z-index: 2;
li {
button {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding-block: 8px;
color: var(--gray500);
font-size: 16px;
font-weight: 400;
line-height: 1.62;
transition: color 0.2s ease;
&:hover {
color: var(--gray800);
}
}
}
}
}

@media (max-width: 768px) {
.commentItemMenu ul {
bottom: -1.3vw;
width: 13.28vw;
border-radius: 2.08vw;
padding-block: 0.52vw;
li button {
padding-block: 1.56vw;
font-size: 1.82vw;
}
}
}

@media (max-width: 425px) {
.commentItemMenu ul {
bottom: -2.35vw;
width: 24vw;
border-radius: 3.76vw;
padding-block: 0.94vw;
li button {
padding-block: 2.82vw;
font-size: 3.29vw;
}
}
}
55 changes: 55 additions & 0 deletions src/components/Detail/styles/DetailBackToListButton.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
.backToList {
margin: 64px auto 0;
background-color: var(--blue);
color: var(--gray100);
font-size: 18px;
font-weight: 600;
height: 48px;
border-radius: 2em;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding-inline: 40px;
border: 1px solid transparent;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
img {
width: 24px;
height: 24px;
transition: filter 0.2s ease;
}
&:hover {
background-color: #fff;
color: var(--blue);
border-color: var(--blue);
img {
filter: invert(40%) sepia(80%) saturate(2619%) hue-rotate(203deg) brightness(101%) contrast(101%);
}
}
}
@media (max-width: 768px) {
.backToList {
margin: 7.29vw auto 0;
font-size: 2.34vw;
height: 6.25vw;
gap: 1.04vw;
padding-inline: 5.21vw;
img {
width: 3.13vw;
height: 3.13vw;
}
}
}
@media (max-width: 425px) {
.backToList {
margin: 9.41vw auto 0;
font-size: 4.24vw;
height: 11.29vw;
gap: 1.88vw;
padding-inline: 9.41vw;
img {
width: 5.65vw;
height: 5.65vw;
}
}
}
Loading
Loading