diff --git a/README.md b/README.md
index 1ea62873..2dc11e04 100644
--- a/README.md
+++ b/README.md
@@ -7,14 +7,60 @@
## 미션 목록
-| 미션 | 날짜 | PR | 주요 내용 |
-| ---- | ---------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
-| 1 | 2025-02-24 | [#10](https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/10) | 랜딩 페이지의 HTML 및 CSS 구현 |
-| 2 | 2025-03-05 | [#44](https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/44) | 회원가입 및 로그인 페이지의 HTML, CSS 구현 |
-| 3 | 2025-03-07 | [#60](https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/60) | 반응형 디자인 구현(desktop-first, 1920px 이상 큰 모니터 기준), breakpoint: 1919px, 1199px, 767px |
-| 4 | 2025-03-18 | [#101](https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/101) | JS기능 추가(DOM 요소 조작 및 이벤트 리스너), 회원가입, 로그인 폼 유효성 검사 |
-| 5 | 2025-05-05 | [#](https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/181) | React, SCSS+CSS modules로 마이그레이션, items 페이지 구현(fetch data, 검색어, 정렬, pagination, 반응형 구현) |
-| 6 | 2025-05-11 | [#](https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/) | 상품 등록 페이지 구현 |
+
{tags.map((tag) => (
-
+
#{tag}
handleRemoveTag(tag)}
diff --git a/src/components/AddItem/TagInput/TagInput.module.scss b/src/components/AddItem/TagInput/TagInput.module.scss
index 5d315eae..20a50bc6 100644
--- a/src/components/AddItem/TagInput/TagInput.module.scss
+++ b/src/components/AddItem/TagInput/TagInput.module.scss
@@ -1,18 +1,19 @@
+// tagHelpers.module.scss로 공통으로 뺐는데 이게 맞는건지 판단 중
.tagList {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
- margin-top: 0.8rem;
-}
-
-.tag {
margin-top: 1.4rem;
- padding: 0.5rem 1.2rem 0.5rem 1.6rem;
- background-color: var(--primary-50);
- border-radius: 26px;
- font-size: 1.6rem;
- color: var(--secondary-800);
- display: flex;
- align-items: center;
- gap: 0.8rem;
}
+
+// .tag {
+// margin-top: 1.4rem;
+// padding: 0.5rem 1.2rem 0.5rem 1.6rem;
+// background-color: var(--primary-50);
+// border-radius: 26px;
+// font-size: 1.6rem;
+// color: var(--secondary-800);
+// display: flex;
+// align-items: center;
+// gap: 0.8rem;
+// }
diff --git a/src/components/Comment/CommentInput/CommentInput.jsx b/src/components/Comment/CommentInput/CommentInput.jsx
new file mode 100644
index 00000000..5a9af341
--- /dev/null
+++ b/src/components/Comment/CommentInput/CommentInput.jsx
@@ -0,0 +1,54 @@
+import { useState } from 'react';
+import { useToast } from '@/contexts';
+import { postComment } from '@/api/comment';
+import { postCommentErrorMessage } from '@/utils/errorMessage';
+import formStyles from '@/styles/helpers/formHelpers.module.scss';
+import buttonStyles from '@/styles/helpers/buttonHelpers.module.scss';
+import styles from './CommentInput.module.scss';
+
+const CommentInput = ({ productId, refreshAfterSubmit }) => {
+ const { showToast } = useToast();
+ const [content, setContent] = useState('');
+
+ const handleSubmit = async () => {
+ if (!content.trim()) return;
+
+ try {
+ await postComment(productId, content);
+
+ setContent('');
+ refreshAfterSubmit(); // 등록 후 댓글 목록 새로고침
+ } catch (error) {
+ showToast(postCommentErrorMessage(error.status), 'error');
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default CommentInput;
diff --git a/src/components/Comment/CommentInput/CommentInput.module.scss b/src/components/Comment/CommentInput/CommentInput.module.scss
new file mode 100644
index 00000000..2baca9b1
--- /dev/null
+++ b/src/components/Comment/CommentInput/CommentInput.module.scss
@@ -0,0 +1,14 @@
+.inputContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 0.9rem;
+}
+
+.submitButton {
+ width: 7.4rem;
+ padding: 0.8rem 2.3rem;
+ border-radius: 8px;
+ font-size: 1.6rem;
+ font-weight: 400;
+ align-self: end;
+}
diff --git a/src/components/Comment/CommentInput/index.js b/src/components/Comment/CommentInput/index.js
new file mode 100644
index 00000000..15707789
--- /dev/null
+++ b/src/components/Comment/CommentInput/index.js
@@ -0,0 +1 @@
+export { default } from './CommentInput';
diff --git a/src/components/Comment/CommentItem/CommentItem.jsx b/src/components/Comment/CommentItem/CommentItem.jsx
new file mode 100644
index 00000000..4c1a072a
--- /dev/null
+++ b/src/components/Comment/CommentItem/CommentItem.jsx
@@ -0,0 +1,109 @@
+import { useState } from 'react';
+import { useToast } from '@/contexts';
+import { deleteComment, patchComment } from '@/api/comment';
+import { VerticalKebabDrop } from '@/components/common/Buttons';
+import { relativeTime } from '@/utils/format';
+import {
+ deleteCommentErrorMessage,
+ patchCommentErrorMessage,
+} from '@/utils/errorMessage';
+import defaultProfile from '@/assets/images/default_profile.svg';
+import buttonStyles from '@/styles/helpers/buttonHelpers.module.scss';
+import formStyles from '@/styles/helpers/formHelpers.module.scss';
+import styles from './CommentItem.module.scss';
+
+const CommentItem = ({ comment, onUpdated }) => {
+ const { showToast } = useToast();
+ const [isEditing, setIsEditing] = useState(false);
+ const [editedContent, setEditedContent] = useState(comment.content);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const handleSelect = (value) => {
+ if (value === 'edit') setIsEditing(true);
+ if (value === 'delete') handleDelete();
+ };
+
+ const handleUpdate = async () => {
+ try {
+ await patchComment(comment.id, editedContent);
+
+ setIsEditing(false);
+ onUpdated(); // 부모에 갱신 요청
+ } catch (error) {
+ showToast(patchCommentErrorMessage(error.status), 'error');
+ }
+ };
+ const handleDelete = async () => {
+ const confirmed = window.confirm('정말 이 댓글을 삭제하시겠습니까?'); //TODO: 모달로 변경
+ if (!confirmed) return;
+
+ setIsDeleting(true);
+ try {
+ await deleteComment(comment.id);
+
+ onUpdated(); // 목록 갱신
+ } catch (error) {
+ showToast(deleteCommentErrorMessage(error.status), 'error');
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default CommentItem;
diff --git a/src/components/Comment/CommentItem/CommentItem.module.scss b/src/components/Comment/CommentItem/CommentItem.module.scss
new file mode 100644
index 00000000..607e35c5
--- /dev/null
+++ b/src/components/Comment/CommentItem/CommentItem.module.scss
@@ -0,0 +1,58 @@
+.commentItem {
+ display: flex;
+ justify-content: space-between;
+ .commentContent {
+ display: flex;
+ flex-direction: column;
+ gap: 2.4rem;
+ width: 100%;
+
+ p {
+ font-size: 1.4rem;
+ font-weight: 400;
+ }
+
+ .commentInfo {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.2rem 0;
+ .writerInfo {
+ display: flex;
+ gap: 0.8rem;
+ width: 100%;
+ img {
+ width: 3.2rem;
+ aspect-ratio: 1 / 1;
+ border-radius: 50%;
+ }
+
+ .nicknameAndDate {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ .nickname {
+ color: var(--secondary-600);
+ }
+ .date {
+ color: var(--secondary-400);
+ }
+ }
+ }
+ }
+ }
+}
+
+.edit {
+ display: flex;
+ justify-content: end;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 1.6rem;
+ width: 100%;
+
+ button {
+ padding: 0.8rem 2.3rem;
+ font-weight: 400;
+ }
+}
diff --git a/src/components/Comment/CommentItem/index.js b/src/components/Comment/CommentItem/index.js
new file mode 100644
index 00000000..454bae7e
--- /dev/null
+++ b/src/components/Comment/CommentItem/index.js
@@ -0,0 +1 @@
+export { default } from './CommentItem';
diff --git a/src/components/Comment/CommentList/CommentList.jsx b/src/components/Comment/CommentList/CommentList.jsx
new file mode 100644
index 00000000..3275cb8d
--- /dev/null
+++ b/src/components/Comment/CommentList/CommentList.jsx
@@ -0,0 +1,70 @@
+import { useEffect, useState } from 'react';
+import { useToast } from '@/contexts';
+import { getComments } from '@/api/comment';
+import { CommentItem } from '@/components/Comment';
+import { safeFetch } from '@/utils/api';
+import { getCommentErrorMessage } from '@/utils/errorMessage';
+import { baseUrl, ENDPOINTS } from '@/constants/urls';
+import noCommentsImg from '@/assets/images/no_inquiries.svg';
+import commonStyles from '@/styles/helpers/commonHelpers.module.scss';
+import styles from './CommentList.module.scss';
+
+const CommentList = ({ productId, refreshKey }) => {
+ const { showToast } = useToast();
+ const [comments, setComments] = useState([]);
+ const [nextCursor, setNextCursor] = useState(null);
+
+ useEffect(() => {
+ fetchComments();
+ }, [productId, refreshKey]);
+
+ const fetchComments = async (cursor) => {
+ const cursorQuery = cursor ? `&cursor=${cursor}` : '';
+
+ try {
+ const data = await getComments(productId, cursor);
+
+ if (cursor) {
+ setComments((prev) => [...prev, ...data.list]);
+ } else {
+ setComments(data.list);
+ }
+
+ setNextCursor(data.nextCursor ?? null);
+ } catch (error) {
+ showToast(getCommentErrorMessage(error.status), 'error');
+ }
+ };
+
+ return (
+
+ {comments.length === 0 ? (
+
+

+
아직 문의가 없어요
+
+ ) : (
+ comments.map((comment) => (
+
+ ))
+ )}
+
+ {/* 페이징 */}
+ {/* {nextCursor && (
+
+ )} */}
+
+ );
+};
+
+export default CommentList;
diff --git a/src/components/Comment/CommentList/CommentList.module.scss b/src/components/Comment/CommentList/CommentList.module.scss
new file mode 100644
index 00000000..d9ccff3a
--- /dev/null
+++ b/src/components/Comment/CommentList/CommentList.module.scss
@@ -0,0 +1,22 @@
+.commentList {
+ display: flex;
+ flex-direction: column;
+}
+
+.emptyCommentList {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 0.8rem;
+ font-size: 1.6rem;
+ color: var(--secondary-400);
+ img {
+ width: 19.6rem;
+ aspect-ratio: 1 / 1;
+ }
+}
+
+.addMargin {
+ margin: 2.4rem 0;
+}
diff --git a/src/components/Comment/CommentList/index.js b/src/components/Comment/CommentList/index.js
new file mode 100644
index 00000000..03a5519c
--- /dev/null
+++ b/src/components/Comment/CommentList/index.js
@@ -0,0 +1 @@
+export { default } from './CommentList';
diff --git a/src/components/Comment/index.js b/src/components/Comment/index.js
new file mode 100644
index 00000000..7bac7654
--- /dev/null
+++ b/src/components/Comment/index.js
@@ -0,0 +1,3 @@
+export { default as CommentInput } from './CommentInput';
+export { default as CommentList } from './CommentList';
+export { default as CommentItem } from './CommentItem';
diff --git a/src/components/Product/AllProductSection/AllProductSection.jsx b/src/components/Product/AllProductSection/AllProductSection.jsx
index 2167f0c7..2e73b410 100644
--- a/src/components/Product/AllProductSection/AllProductSection.jsx
+++ b/src/components/Product/AllProductSection/AllProductSection.jsx
@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useResponsivePageSize } from '@/hooks';
-import { SortSelect, Pagination, useToast } from '@/components/common';
+import { useToast } from '@/contexts';
+import { fetchAllProducts } from '@/api/product';
+import { Pagination } from '@/components/common';
+import { SortDrop } from '@/components/common/Buttons';
import { ProductCard } from '@/components/Product';
import { safeFetch } from '@/utils/api';
+import { getProductErrorMessage } from '@/utils/errorMessage';
import { baseUrl, ENDPOINTS, ROUTES } from '@/constants/urls';
-import { PRODUCT_ERROR_MESSAGES } from '@/constants/messages';
import buttonStyles from '@/styles/helpers/buttonHelpers.module.scss';
import styles from './AllProductSection.module.scss';
@@ -17,18 +20,27 @@ const AllProductSection = () => {
const [searchQuery, setSearchQuery] = useState('');
const [page, setPage] = useState(1);
const pageSize = useResponsivePageSize();
+ const options = [
+ { value: 'recent', label: '최신순' },
+ { value: 'favorite', label: '좋아요순' },
+ ];
useEffect(() => {
const fetchProducts = async () => {
- const data = await safeFetch({
- url: `${baseUrl}${ENDPOINTS.PRODUCTS}?page=${page}&pageSize=${pageSize}&orderBy=${sortOption}&keyword=${encodeURIComponent(searchQuery)}`,
- options: { method: 'GET' },
- showToast,
- uiErrorMessage: PRODUCT_ERROR_MESSAGES.FETCH_ALL_FAILED,
- });
- setProducts(data.list);
- setTotalCount(data.totalCount);
+ try {
+ const data = await fetchAllProducts({
+ page,
+ pageSize,
+ orderBy: sortOption,
+ keyword: searchQuery,
+ });
+ setProducts(data.list);
+ setTotalCount(data.totalCount);
+ } catch (error) {
+ showToast(getProductErrorMessage(error.status), 'error');
+ }
};
+
fetchProducts();
}, [page, sortOption, pageSize, searchQuery]);
@@ -59,13 +71,10 @@ const AllProductSection = () => {
상품 등록하기
-
diff --git a/src/components/Product/BestProductSection/BestProductSection.jsx b/src/components/Product/BestProductSection/BestProductSection.jsx
index 15e01511..3cc12ae6 100644
--- a/src/components/Product/BestProductSection/BestProductSection.jsx
+++ b/src/components/Product/BestProductSection/BestProductSection.jsx
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react';
+import { useToast } from '@/contexts';
+import { fetchBestProducts } from '@/api/product';
import { ProductCard } from '@/components/Product';
-import { useToast } from '@/components/common';
-import { safeFetch } from '@/utils/api';
-import { baseUrl, ENDPOINTS } from '@/constants/urls';
-import { PRODUCT_ERROR_MESSAGES } from '@/constants/messages';
+import { getProductErrorMessage } from '@/utils/errorMessage';
import styles from './BestProductSection.module.scss';
const BestProductSection = () => {
@@ -11,17 +10,16 @@ const BestProductSection = () => {
const [bestProducts, setBestProducts] = useState([]);
useEffect(() => {
- const fetchBestProducts = async () => {
- const data = await safeFetch({
- url: `${baseUrl}${ENDPOINTS.PRODUCTS}?page=1&pageSize=4&orderBy=favorite`,
- options: { method: 'GET' },
- showToast,
- uiErrorMessage: PRODUCT_ERROR_MESSAGES.FETCH_BEST_FAILED,
- });
- const top4 = data.list;
- setBestProducts(top4);
+ const fetchData = async () => {
+ try {
+ const data = await fetchBestProducts();
+ setBestProducts(data.list);
+ } catch (error) {
+ showToast(getProductErrorMessage(error.status), 'error');
+ }
};
- fetchBestProducts();
+
+ fetchData();
}, []);
return (
diff --git a/src/components/Product/ProductCard/ProductCard.jsx b/src/components/Product/ProductCard/ProductCard.jsx
index b1f212d8..279c12ea 100644
--- a/src/components/Product/ProductCard/ProductCard.jsx
+++ b/src/components/Product/ProductCard/ProductCard.jsx
@@ -1,4 +1,4 @@
-import heartIcon from '@/assets/icons/heart_empty.svg';
+import { FavoriteBtn } from '@/components/common/Buttons';
import defaultProductImg from '@/assets/images/default_product.svg';
import styles from './ProductCard.module.scss';
@@ -6,6 +6,7 @@ const ProductCard = ({ images, name, price, favoriteCount }) => {
return (

{
@@ -23,10 +24,15 @@ const ProductCard = ({ images, name, price, favoriteCount }) => {
? `${price.toLocaleString()}원`
: '가격 미정'}
-
+
+ {/*
{favoriteCount}
-
+
*/}
);
diff --git a/src/components/Product/ProductCard/ProductCard.module.scss b/src/components/Product/ProductCard/ProductCard.module.scss
index 78f609c4..10082835 100644
--- a/src/components/Product/ProductCard/ProductCard.module.scss
+++ b/src/components/Product/ProductCard/ProductCard.module.scss
@@ -3,7 +3,7 @@
border-radius: 16px;
overflow: hidden;
- img {
+ .productImg {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
@@ -27,21 +27,13 @@
font-weight: 700;
color: var(--secondary-800);
}
+ }
+}
- .favoriteCount {
- display: flex;
-
- .heartIcon {
- width: 1.6rem;
- height: 1.6rem;
- object-fit: contain;
- margin-right: 4px;
- }
+.favoriteCountFontSize {
+ font-size: 1.2rem;
+}
- .count {
- font-size: 1.2rem;
- color: var(--secondary-600);
- }
- }
- }
+.favoriteIconSize {
+ width: 1.6rem;
}
diff --git a/src/components/Product/ProductInfo/ProductInfo.jsx b/src/components/Product/ProductInfo/ProductInfo.jsx
new file mode 100644
index 00000000..a52542d1
--- /dev/null
+++ b/src/components/Product/ProductInfo/ProductInfo.jsx
@@ -0,0 +1,111 @@
+import { useNavigate } from 'react-router-dom';
+import { useUser, useToast } from '@/contexts';
+import { deleteProduct } from '@/api/product';
+import { FavoriteBtn, VerticalKebabDrop } from '@/components/common/Buttons';
+import { isoDate } from '@/utils/format';
+import { deleteProductErrorMessage } from '@/utils/errorMessage';
+import { ROUTES } from '@/constants/urls';
+import { PRODUCT_SUCCESS_MESSAGES } from '@/constants/messages';
+import defaultProfileImg from '@/assets/images/default_profile.svg';
+import tagStyles from '@/styles/helpers/tagHelpers.module.scss';
+import commonStyles from '@/styles/helpers/commonHelpers.module.scss';
+import styles from './ProductInfo.module.scss';
+
+const ProductInfo = ({
+ id: productId,
+ images,
+ name,
+ description,
+ price,
+ tags = [],
+ favoriteCount,
+ ownerNickname,
+ updatedAt,
+}) => {
+ const currentUser = useUser(); // 지금 로그인한 사용자
+ const { showToast } = useToast();
+ const navigate = useNavigate();
+ console.log('🧪 navigate에서 넘기는 productId:', productId);
+
+ const handleEdit = () => {
+ navigate(ROUTES.EDIT_ITEM(productId));
+ };
+
+ const handleDelete = async () => {
+ const confirmed = window.confirm('정말 삭제하시겠어요?');
+ if (!confirmed) return;
+
+ try {
+ const result = await deleteProduct({ productId });
+
+ if (result) {
+ showToast(PRODUCT_SUCCESS_MESSAGES.DELETE_ITEM_SUCCESS, 'success');
+ navigate(ROUTES.ITEMS);
+ }
+ } catch (error) {
+ const status = error?.status || error?.response?.status;
+ showToast(deleteProductErrorMessage(status), 'error');
+ }
+ };
+ const handleSelect = (value) => {
+ if (value === 'edit') handleEdit();
+ if (value === 'delete') handleDelete();
+ };
+
+ return (
+
+
+

+
+
+
+
+
+
{name}
+
+
+
{price?.toLocaleString()}원
+
+
+
+
상품 소개
+
{description}
+
+
+
상품 태그
+
+ {tags.map((tag, index) => (
+
+ #{tag}
+
+ ))}
+
+
+
+
+
+

+
+
{ownerNickname}
+
{isoDate(updatedAt)}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ProductInfo;
diff --git a/src/components/Product/ProductInfo/ProductInfo.module.scss b/src/components/Product/ProductInfo/ProductInfo.module.scss
new file mode 100644
index 00000000..895b0651
--- /dev/null
+++ b/src/components/Product/ProductInfo/ProductInfo.module.scss
@@ -0,0 +1,136 @@
+.productContainer {
+ display: flex;
+ gap: 2.4rem;
+
+ .productImage {
+ display: block;
+ width: 48.6rem;
+ aspect-ratio: 1 / 1;
+ border-radius: 28.59px;
+ overflow: hidden;
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ .productOverview {
+ display: flex;
+ flex-direction: column;
+ gap: 6.2rem;
+ width: 100%;
+ }
+}
+
+.productInfo {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ gap: 2.4rem;
+}
+
+.infoHeader {
+ display: flex;
+ flex-direction: column;
+ gap: 1.6rem;
+
+ .titleBar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-weight: 600;
+ h2 {
+ font-size: 2.4rem;
+ }
+ }
+}
+
+.price {
+ font-size: 4rem;
+}
+
+.infoLabel {
+ display: flex;
+ flex-direction: column;
+ gap: 1.6rem;
+ font-size: 1.6rem;
+ color: var(--secondary-600);
+
+ h3 {
+ font-weight: 600;
+ }
+
+ p {
+ line-height: 2.6rem;
+ font-weight: 400;
+ }
+}
+
+.productPostInfo {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1.6rem;
+ font-size: 1.4rem;
+ padding: 0.5rem 0;
+
+ .writerInfo {
+ display: flex;
+ align-items: center;
+ gap: 1.6rem;
+
+ img {
+ width: 4rem;
+ aspect-ratio: 1 / 1;
+ border-radius: 50%;
+ object-fit: cover;
+ }
+
+ .nicknameAndDate {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ .nickname {
+ color: var(--secondary-600);
+ }
+ .date {
+ color: var(--secondary-400);
+ }
+ }
+ }
+
+ .favoriteContainer {
+ border-left: 1px solid var(--secondary-200);
+ border-radius: 2px;
+ padding-left: 2.4rem;
+ }
+}
+
+.favoriteBox {
+ border: 1px solid var(--secondary-200);
+ border-radius: 35px;
+ padding: 0.8rem 1.5rem;
+}
+
+.favoriteCountFontSize {
+ font-size: 1.6rem;
+}
+
+.favoriteIconSize {
+ width: 3.2rem;
+}
+
+@media (max-width: 767px) {
+ .productContainer {
+ flex-direction: column;
+
+ .productImage {
+ width: 100%;
+ }
+
+ .productOverview {
+ gap: 4rem;
+ }
+ }
+}
diff --git a/src/components/Product/ProductInfo/index.js b/src/components/Product/ProductInfo/index.js
new file mode 100644
index 00000000..8d4dff20
--- /dev/null
+++ b/src/components/Product/ProductInfo/index.js
@@ -0,0 +1 @@
+export { default } from './ProductInfo';
diff --git a/src/components/Product/index.js b/src/components/Product/index.js
index af7b8977..38e58740 100644
--- a/src/components/Product/index.js
+++ b/src/components/Product/index.js
@@ -1,3 +1,4 @@
export { default as ProductCard } from './ProductCard';
export { default as BestProductSection } from './BestProductSection';
export { default as AllProductSection } from './AllProductSection';
+export { default as ProductInfo } from './ProductInfo';
diff --git a/src/components/common/Buttons/Dropdown/DropdownBtn.jsx b/src/components/common/Buttons/Dropdown/DropdownBtn.jsx
new file mode 100644
index 00000000..3c0a9c7f
--- /dev/null
+++ b/src/components/common/Buttons/Dropdown/DropdownBtn.jsx
@@ -0,0 +1,65 @@
+import { useDropdown } from '@/hooks';
+import styles from './DropdownBtn.module.scss';
+
+const DropdownBtn = ({
+ mode,
+ label = '',
+ iconSrc,
+ iconAlt,
+ options = [],
+ buttonClassName = '',
+ optionListClassName = '',
+ onSelect,
+}) => {
+ const { isOpen, toggle, close, dropdownRef } = useDropdown();
+ const wrapperClass = `${styles.wrapper} ${styles[`wrapper--${mode}`]}`;
+
+ const textDropdown = () => (
+
+ );
+
+ const iconDropdown = () => (
+
+ );
+
+ return (
+
+ {mode === 'textMode' ? textDropdown() : iconDropdown()}
+
+ {isOpen && (
+
+ {options.map(({ label, value }) => (
+ -
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default DropdownBtn;
diff --git a/src/components/common/SortSelect/SortSelect.module.scss b/src/components/common/Buttons/Dropdown/DropdownBtn.module.scss
similarity index 64%
rename from src/components/common/SortSelect/SortSelect.module.scss
rename to src/components/common/Buttons/Dropdown/DropdownBtn.module.scss
index 70982aba..772691ae 100644
--- a/src/components/common/SortSelect/SortSelect.module.scss
+++ b/src/components/common/Buttons/Dropdown/DropdownBtn.module.scss
@@ -1,10 +1,20 @@
.wrapper {
position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
font-size: 16px;
- min-width: 140px;
+
+ &--textMode {
+ min-width: 140px;
+ }
+ &--iconMode {
+ width: fit-content;
+ height: fit-content;
+ }
}
-.selectButton {
+.textMode {
width: 100%;
padding: 1.2rem 2rem;
border: 1px solid var(--secondary-200);
@@ -28,14 +38,32 @@
}
}
+.iconMode {
+ aspect-ratio: 1 / 1;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+
+ &:hover,
+ &:focus,
+ &:active {
+ color: inherit;
+ }
+
+ button {
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+ }
+}
+
.optionList {
position: absolute;
- top: calc(100% + 8px);
- width: 100%;
font-size: 1.6rem;
background: var(--white);
border: 1px solid var(--secondary-200);
- border-radius: 12px;
overflow: hidden;
}
@@ -51,6 +79,10 @@
&:hover {
background-color: var(--secondary-100);
}
+
+ button {
+ width: 100%;
+ }
}
@media (max-width: 767px) {
@@ -58,38 +90,4 @@
min-width: auto;
width: fit-content;
}
-
- .sortIcon {
- width: 42px;
- aspect-ratio: 1 / 1;
- background-color: var(--white);
- border: 1px solid var(--secondary-200);
- border-radius: 12px;
- padding: 0.9rem;
-
- display: inline-flex;
- justify-content: center;
- align-items: center;
-
- &:hover,
- &:focus,
- &:active {
- background-color: var(--white);
- color: inherit;
- }
-
- img {
- width: 100%;
- height: 100%;
- object-fit: contain;
- }
- }
-
- .optionList {
- width: 14rem;
- top: calc(100% + 8px);
- right: 0;
- border-radius: 12px;
- overflow: hidden;
- }
}
diff --git a/src/components/common/Buttons/Dropdown/index.js b/src/components/common/Buttons/Dropdown/index.js
new file mode 100644
index 00000000..9afba0d5
--- /dev/null
+++ b/src/components/common/Buttons/Dropdown/index.js
@@ -0,0 +1 @@
+export { default } from './DropdownBtn';
diff --git a/src/components/common/Buttons/FavoriteBtn/FavoriteBtn.jsx b/src/components/common/Buttons/FavoriteBtn/FavoriteBtn.jsx
new file mode 100644
index 00000000..29fe107d
--- /dev/null
+++ b/src/components/common/Buttons/FavoriteBtn/FavoriteBtn.jsx
@@ -0,0 +1,32 @@
+import heartIcon from '@/assets/icons/heart_empty.svg';
+import styles from './FavoriteBtn.module.scss';
+
+const FavoriteBtn = ({
+ favoriteCount,
+ onClick,
+ iconSizeClassName = '',
+ fontSizeClassName = '',
+ heartBoxClassName = '',
+}) => {
+ return (
+
+ );
+};
+
+export default FavoriteBtn;
diff --git a/src/components/common/Buttons/FavoriteBtn/FavoriteBtn.module.scss b/src/components/common/Buttons/FavoriteBtn/FavoriteBtn.module.scss
new file mode 100644
index 00000000..9a05654f
--- /dev/null
+++ b/src/components/common/Buttons/FavoriteBtn/FavoriteBtn.module.scss
@@ -0,0 +1,19 @@
+.favoriteCount {
+ display: flex;
+
+ .favoriteBox {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+
+ .heartIcon {
+ aspect-ratio: 1 / 1;
+ object-fit: contain;
+ }
+
+ .count {
+ color: var(--secondary-600);
+ }
+ }
+}
diff --git a/src/components/common/Buttons/FavoriteBtn/index.js b/src/components/common/Buttons/FavoriteBtn/index.js
new file mode 100644
index 00000000..b493a5f9
--- /dev/null
+++ b/src/components/common/Buttons/FavoriteBtn/index.js
@@ -0,0 +1 @@
+export { default } from './FavoriteBtn';
diff --git a/src/components/common/Buttons/SortDrop/SortDrop.jsx b/src/components/common/Buttons/SortDrop/SortDrop.jsx
new file mode 100644
index 00000000..974bc3f7
--- /dev/null
+++ b/src/components/common/Buttons/SortDrop/SortDrop.jsx
@@ -0,0 +1,34 @@
+import { useState, useEffect } from 'react';
+import { DropdownBtn } from '@/components/common/Buttons';
+import sortIcon from '@/assets/icons/sort.svg';
+import styles from './SortDrop.module.scss';
+
+const SortDrop = ({ value, onChange, options = [] }) => {
+ const [isMobile, setIsMobile] = useState(false);
+
+ const selectedOption = options.find((opt) => opt.value === value);
+
+ useEffect(() => {
+ const handleResize = () => {
+ setIsMobile(window.innerWidth <= 767);
+ };
+ handleResize();
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ return (
+
+ );
+};
+
+export default SortDrop;
diff --git a/src/components/common/Buttons/SortDrop/SortDrop.module.scss b/src/components/common/Buttons/SortDrop/SortDrop.module.scss
new file mode 100644
index 00000000..26e192c8
--- /dev/null
+++ b/src/components/common/Buttons/SortDrop/SortDrop.module.scss
@@ -0,0 +1,25 @@
+.sortButton {
+ width: 42px;
+ background-color: var(--white);
+ border: 1px solid var(--secondary-200);
+ border-radius: 12px;
+ padding: 0.9rem;
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: var(--white);
+ }
+}
+
+.optionList {
+ width: 100%;
+ top: calc(100% + 8px);
+ border-radius: 12px;
+}
+
+@media (max-width: 767px) {
+ .optionList {
+ width: 14rem;
+ right: 0;
+ }
+}
diff --git a/src/components/common/Buttons/SortDrop/index.js b/src/components/common/Buttons/SortDrop/index.js
new file mode 100644
index 00000000..36606651
--- /dev/null
+++ b/src/components/common/Buttons/SortDrop/index.js
@@ -0,0 +1 @@
+export { default } from './SortDrop';
diff --git a/src/components/common/Buttons/VerticalKebabDrop/VerticalKebabDrop.jsx b/src/components/common/Buttons/VerticalKebabDrop/VerticalKebabDrop.jsx
new file mode 100644
index 00000000..7e3332d2
--- /dev/null
+++ b/src/components/common/Buttons/VerticalKebabDrop/VerticalKebabDrop.jsx
@@ -0,0 +1,23 @@
+import { DropdownBtn } from '@/components/common/Buttons';
+import kebabIcon from '@/assets/icons/vertical_kebab.svg';
+import styles from './VerticalKebabDrop.module.scss';
+
+const VerticalKebabDrop = ({ onSelect }) => {
+ const options = [
+ { label: '수정하기', value: 'edit' },
+ { label: '삭제하기', value: 'delete' },
+ ];
+ return (
+
+ );
+};
+
+export default VerticalKebabDrop;
diff --git a/src/components/common/Buttons/VerticalKebabDrop/VerticalKebabDrop.module.scss b/src/components/common/Buttons/VerticalKebabDrop/VerticalKebabDrop.module.scss
new file mode 100644
index 00000000..9ff9dcb0
--- /dev/null
+++ b/src/components/common/Buttons/VerticalKebabDrop/VerticalKebabDrop.module.scss
@@ -0,0 +1,16 @@
+.kebabButton {
+ width: 24px;
+}
+
+.optionList {
+ width: 14rem;
+ top: calc(100% + 10px);
+ right: 0;
+ border-radius: 8px;
+}
+
+@media (max-width: 767px) {
+ .optionList {
+ width: 10rem;
+ }
+}
diff --git a/src/components/common/Buttons/VerticalKebabDrop/index.js b/src/components/common/Buttons/VerticalKebabDrop/index.js
new file mode 100644
index 00000000..5ca7c827
--- /dev/null
+++ b/src/components/common/Buttons/VerticalKebabDrop/index.js
@@ -0,0 +1 @@
+export { default } from './VerticalKebabDrop';
diff --git a/src/components/common/Buttons/index.js b/src/components/common/Buttons/index.js
index a3fc3302..bd65108f 100644
--- a/src/components/common/Buttons/index.js
+++ b/src/components/common/Buttons/index.js
@@ -1 +1,5 @@
export { default as RemoveIcon } from './RemoveIcon';
+export { default as VerticalKebabDrop } from './VerticalKebabDrop';
+export { default as SortDrop } from './SortDrop';
+export { default as DropdownBtn } from './Dropdown';
+export { default as FavoriteBtn } from './FavoriteBtn';
diff --git a/src/components/common/SortSelect/SortSelect.jsx b/src/components/common/SortSelect/SortSelect.jsx
deleted file mode 100644
index fa3b358b..00000000
--- a/src/components/common/SortSelect/SortSelect.jsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { useState, useRef, useEffect } from 'react';
-import sortIcon from '@/assets/icons/sort.svg';
-import styles from './SortSelect.module.scss';
-
-const SortSelect = ({ value, onChange, options = [] }) => {
- const [isOpen, setIsOpen] = useState(false);
- const [isMobile, setIsMobile] = useState(false);
- const ref = useRef();
-
- const selectedOption = options.find((opt) => opt.value === value);
-
- useEffect(() => {
- const handleResize = () => {
- setIsMobile(window.innerWidth <= 767);
- };
- handleResize();
- window.addEventListener('resize', handleResize);
- return () => window.removeEventListener('resize', handleResize);
- }, []);
-
- useEffect(() => {
- const handleClickOutside = (e) => {
- if (ref.current && !ref.current.contains(e.target)) {
- setIsOpen(false);
- }
- };
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }, []);
-
- return (
-
- {isMobile ? (
-
- ) : (
-
- )}
- {isOpen && (
-
- {options.map((opt) => (
- -
-
-
- ))}
-
- )}
-
- );
-};
-
-export default SortSelect;
diff --git a/src/components/common/SortSelect/index.js b/src/components/common/SortSelect/index.js
deleted file mode 100644
index 78d697f2..00000000
--- a/src/components/common/SortSelect/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './SortSelect';
diff --git a/src/components/common/Toast/index.js b/src/components/common/Toast/index.js
index 3fb257fe..55a859a0 100644
--- a/src/components/common/Toast/index.js
+++ b/src/components/common/Toast/index.js
@@ -1,2 +1 @@
-export { useToast, ToastProvider } from './ToastProvider';
export { default } from './Toast';
diff --git a/src/components/common/index.js b/src/components/common/index.js
index ee957fb4..fbe8cf6b 100644
--- a/src/components/common/index.js
+++ b/src/components/common/index.js
@@ -2,5 +2,4 @@ export { default as Header } from './Header';
export { default as Footer } from './Footer';
export { default as Logo } from './Logo';
export { default as Pagination } from './Pagination';
-export { default as SortSelect } from './SortSelect';
-export { useToast, ToastProvider, default as Toast } from './Toast';
+export { default as Toast } from './Toast';
diff --git a/src/constants/messages/comment.js b/src/constants/messages/comment.js
new file mode 100644
index 00000000..e719a85c
--- /dev/null
+++ b/src/constants/messages/comment.js
@@ -0,0 +1,24 @@
+export const COMMENT_ERROR_MESSAGES = {
+ // 공통
+ UNAUTHORIZED: '로그인이 필요합니다.',
+ SERVER_ERROR: '서버에 문제가 발생했어요. 잠시 후 다시 시도해주세요.',
+
+ // GET
+ FORBIDDEN_FETCH: '이 댓글을 볼 권한이 없어요.',
+ NOT_FOUND_FETCH: '등록된 문의가 없습니다.',
+ FETCH_FAILED: '댓글을 불러오는 데 실패했어요.',
+
+ // POST
+ FORBIDDEN_POST: '댓글을 등록할 권한이 없어요.',
+ POST_FAILED: '댓글을 등록하는 데 실패했어요.',
+
+ // PATCH
+ FORBIDDEN_PATCH: '이 댓글을 수정할 권한이 없어요.',
+ NOT_FOUND_PATCH: '수정할 댓글을 찾을 수 없어요.',
+ PATCH_FAILED: '댓글을 수정하는 데 실패했어요.',
+
+ // DELETE
+ FORBIDDEN_DELETE: '이 댓글을 삭제할 권한이 없어요.',
+ NOT_FOUND_DELETE: '삭제할 댓글을 찾을 수 없어요.',
+ DELETE_FAILED: '댓글을 삭제하는 데 실패했어요.',
+};
diff --git a/src/constants/messages/index.js b/src/constants/messages/index.js
index d025c8ff..10cb628e 100644
--- a/src/constants/messages/index.js
+++ b/src/constants/messages/index.js
@@ -5,3 +5,4 @@ export {
PRODUCT_SUCCESS_MESSAGES,
} from './product';
export { HTTP_ERROR_MESSAGES } from './common';
+export { COMMENT_ERROR_MESSAGES } from './comment';
diff --git a/src/constants/messages/product.js b/src/constants/messages/product.js
index 9c1cf9d2..9b72e7b9 100644
--- a/src/constants/messages/product.js
+++ b/src/constants/messages/product.js
@@ -1,14 +1,43 @@
+export const PRODUCT_SUCCESS_MESSAGES = {
+ // POST
+ ADD_ITEM_SUCCESS: '상품이 등록되었습니다!',
+
+ // DELETE
+ DELETE_ITEM_SUCCESS: '상품이 삭제되었어요.',
+};
+
export const PRODUCT_INFO_MESSAGES = {
emptyList: '등록된 상품이 없습니다.',
maxImageCount: '* 이미지는 최대 1개까지만 등록할 수 있습니다.',
};
export const PRODUCT_ERROR_MESSAGES = {
- FETCH_ALL_FAILED: '상품을 불러오는 데 실패했습니다.',
+ // 공통
+ UNAUTHORIZED: '로그인이 필요합니다.',
+ SERVER_ERROR: '서버에 문제가 발생했어요. 잠시 후 다시 시도해주세요.',
+
+ // GET
+ FORBIDDEN_FETCH: '이 상품을 볼 권한이 없어요.',
+ NOT_FOUND_FETCH: '해당 상품은 삭제되었어요.',
+ FETCH_FAILED: '상품 정보를 불러오는 데 실패했어요.',
+
+ // POST
+ FORBIDDEN_POST: '상품을 등록할 권한이 없어요.',
+ POST_FAILED: '상품을 등록하는 데 실패했어요.',
+
+ // PATCH
+ FORBIDDEN_PATCH: '상품을 수정할 권한이 없어요.',
+ NOT_FOUND_PATCH: '수정할 상품을 찾을 수 없어요.',
+ PATCH_FAILED: '상품을 수정하는 데 실패했어요.',
+
+ // DELETE
+ FORBIDDEN_DELETE: '상품을 삭제할 권한이 없어요.',
+ NOT_FOUND_DELETE: '삭제할 상품을 찾을 수 없어요.',
+ DELETE_FAILED: '상품을 삭제하는 데 실패했어요.',
+
+ // 목록 관련
+ FETCH_ALL_FAILED: '상품 목록을 불러오는 데 실패했습니다.',
FETCH_BEST_FAILED: '베스트 상품을 불러오는 데 실패했습니다.',
+ FETCH_COMMENTS_FAILED: '댓글 정보를 불러오지 못했습니다.',
ADD_ITEM_FAILED: '상품 등록 중 문제가 발생했습니다.',
};
-
-export const PRODUCT_SUCCESS_MESSAGES = {
- ADD_ITEM_SUCCESS: '상품이 등록되었습니다!',
-};
diff --git a/src/constants/urls/api.js b/src/constants/urls/api.js
index 569b44f3..aa65c768 100644
--- a/src/constants/urls/api.js
+++ b/src/constants/urls/api.js
@@ -2,5 +2,6 @@ export const baseUrl = import.meta.env.VITE_BASE_URL;
export const ENDPOINTS = {
PRODUCTS: '/products',
+ COMMENTS: '/comments',
UPLOAD_IMAGE: '/images/upload',
};
diff --git a/src/constants/urls/routes.js b/src/constants/urls/routes.js
index c4644d54..2009f339 100644
--- a/src/constants/urls/routes.js
+++ b/src/constants/urls/routes.js
@@ -6,7 +6,9 @@ const ROUTES = {
PRIVACY: '/privacy',
FAQ: '/faq',
ADD_ITEM: '/additem',
+ ITEM_DETAIL: (id = ':productId') => `/items/${id}`,
BOARD: '/board',
+ EDIT_ITEM: (id = ':productId') => `/items/${id}/edit`,
};
export default ROUTES;
diff --git a/src/contexts/ModalContext.jsx b/src/contexts/ModalContext.jsx
new file mode 100644
index 00000000..e69de29b
diff --git a/src/contexts/ThemeContext.jsx b/src/contexts/ThemeContext.jsx
new file mode 100644
index 00000000..e69de29b
diff --git a/src/components/common/Toast/ToastProvider.jsx b/src/contexts/ToastContext.jsx
similarity index 81%
rename from src/components/common/Toast/ToastProvider.jsx
rename to src/contexts/ToastContext.jsx
index 08727a5f..7dbb195b 100644
--- a/src/components/common/Toast/ToastProvider.jsx
+++ b/src/contexts/ToastContext.jsx
@@ -1,6 +1,6 @@
import { createContext, useContext, useState } from 'react';
-import Toast from './Toast';
-import styles from './Toast.module.scss';
+import { Toast } from '@/components/common';
+import styles from '@/components/common/Toast/Toast.module.scss';
const ToastContext = createContext();
@@ -10,6 +10,8 @@ export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([]);
const showToast = (message, type = 'info') => {
+ if (toasts.some((toast) => toast.message === message)) return;
+
const id = Date.now();
setToasts((prev) => [...prev, { id, message, type }]);
diff --git a/src/contexts/UserContext.jsx b/src/contexts/UserContext.jsx
new file mode 100644
index 00000000..b7c672e1
--- /dev/null
+++ b/src/contexts/UserContext.jsx
@@ -0,0 +1,7 @@
+import { createContext, useContext } from 'react';
+
+export const UserContext = createContext(null);
+export const useUser = () => useContext(UserContext);
+export const UserProvider = ({ children, user }) => {
+ return
{children};
+};
diff --git a/src/contexts/index.js b/src/contexts/index.js
new file mode 100644
index 00000000..03383441
--- /dev/null
+++ b/src/contexts/index.js
@@ -0,0 +1,2 @@
+export { useToast, ToastProvider } from './ToastContext';
+export { useUser, UserProvider } from './UserContext';
diff --git a/src/hooks/index.js b/src/hooks/index.js
index b7bc8654..5524fb1a 100644
--- a/src/hooks/index.js
+++ b/src/hooks/index.js
@@ -1,3 +1,4 @@
export { default as useResponsivePageSize } from './useResponsivePageSize';
export { default as useAuthForm } from './useAuthForm';
export { default as useAddItemForm } from './useAddItemForm';
+export { default as useDropdown } from './useDropdown';
diff --git a/src/hooks/useDropdown.js b/src/hooks/useDropdown.js
new file mode 100644
index 00000000..61224cca
--- /dev/null
+++ b/src/hooks/useDropdown.js
@@ -0,0 +1,31 @@
+import { useState, useRef, useEffect } from 'react';
+
+const useDropdown = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ const open = () => setIsOpen(true);
+ const close = () => setIsOpen(false);
+ const toggle = () => setIsOpen((prev) => !prev);
+
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
+ close();
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ return {
+ isOpen,
+ open,
+ close,
+ toggle,
+ dropdownRef,
+ };
+};
+
+export default useDropdown;
diff --git a/src/main.jsx b/src/main.jsx
index c8e5bd0f..8075fb69 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -2,17 +2,27 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import AppRoutes from '@/routes';
-import { ToastProvider } from '@/components/common/Toast';
+import { ToastProvider, UserProvider } from '@/contexts';
import '@/styles/common/index.css';
const root = document.getElementById('root');
+const mockUser = {
+ id: 1,
+ nickname: 'sienna',
+ image: '/images/sienna-profile.png',
+ updatedAt: '2025-05-12T13:05:18.035Z',
+ createdAt: '2025-05-12T13:05:18.035Z',
+};
+
createRoot(root).render(
-
-
-
+
+
+
+
+
,
);
diff --git a/src/pages/AddItem/AddItem.jsx b/src/pages/AddItem/AddItem.jsx
index 249c4d05..82130ac7 100644
--- a/src/pages/AddItem/AddItem.jsx
+++ b/src/pages/AddItem/AddItem.jsx
@@ -1,14 +1,14 @@
import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import { useAddItemForm } from '@/hooks';
+import { useToast } from '@/contexts';
+import { postProduct, uploadImage } from '@/api/product';
import { ImageUploader, TagInput, AddItemForm } from '@/components/AddItem';
-import { useToast } from '@/components/common/Toast';
import { addItemValidation } from '@/utils/validators';
import { safeFetch } from '@/utils/api';
-import { baseUrl, ENDPOINTS } from '@/constants/urls';
-import {
- PRODUCT_ERROR_MESSAGES,
- PRODUCT_SUCCESS_MESSAGES,
-} from '@/constants/messages';
+import { postProductErrorMessage } from '@/utils/errorMessage';
+import { baseUrl, ENDPOINTS, ROUTES } from '@/constants/urls';
+import { PRODUCT_SUCCESS_MESSAGES } from '@/constants/messages';
import formStyles from '@/styles/helpers/formHelpers.module.scss';
import buttonStyles from '@/styles/helpers/buttonHelpers.module.scss';
import styles from './AddItem.module.scss';
@@ -22,6 +22,7 @@ const initialForm = {
};
const AddItem = () => {
+ const navigate = useNavigate();
const { showToast } = useToast();
const { formData, isFormValid, handleInputChange } = useAddItemForm(
initialForm,
@@ -35,28 +36,31 @@ const AddItem = () => {
const handleSubmit = async (e) => {
e.preventDefault();
- const form = new FormData();
- form.append('image', formData.imageFile);
- form.append('productName', formData.productName);
- form.append('description', formData.description);
- form.append('price', formData.price);
- form.append('tags', JSON.stringify(formData.tags));
+ try {
+ // 1단계: 이미지 업로드
+ const imageForm = new FormData();
+ imageForm.append('image', formData.imageFile);
- const data = await safeFetch({
- url: `${baseUrl}${ENDPOINTS.UPLOAD_IMAGE}`,
- options: {
- method: 'POST',
- body: form,
- },
- showToast,
- uiErrorMessage: PRODUCT_ERROR_MESSAGES.ADD_ITEM_FAILED,
- });
+ const { imageUrl } = await uploadImage(imageForm);
- showToast(
- `${data.message || PRODUCT_SUCCESS_MESSAGES.ADD_ITEM_SUCCESS}`,
- 'success',
- );
- // TODO: 등록 성공 후 상세 페이지로 이동 처리
+ // 2단계: 상품 등록
+ const productForm = new FormData();
+ productForm.append('imageUrl', imageUrl);
+ productForm.append('productName', formData.productName);
+ productForm.append('description', formData.description);
+ productForm.append('price', formData.price);
+ productForm.append('tags', JSON.stringify(formData.tags));
+
+ const data = await postProduct(productForm);
+
+ showToast(
+ data.message || PRODUCT_SUCCESS_MESSAGES.ADD_ITEM_SUCCESS,
+ 'success',
+ );
+ navigate(`${ROUTES.ITEMS}/${data.id}`);
+ } catch (error) {
+ showToast(postProductErrorMessage(error.status), 'error');
+ }
};
return (
diff --git a/src/pages/EditItem/EditItem.jsx b/src/pages/EditItem/EditItem.jsx
new file mode 100644
index 00000000..0ab5ef75
--- /dev/null
+++ b/src/pages/EditItem/EditItem.jsx
@@ -0,0 +1,17 @@
+import { useParams } from 'react-router-dom';
+
+const EditItem = () => {
+ const { productId } = useParams();
+ console.log('productId:', productId);
+ console.log('📦 useParams:', productId);
+
+ return (
+
+
상품 수정 페이지
+
상품 ID: {productId}
+
⛏️ 만들어질 예정입니다!
+
+ );
+};
+
+export default EditItem;
diff --git a/src/pages/EditItem/EditItem.module.scss b/src/pages/EditItem/EditItem.module.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/src/pages/EditItem/index.js b/src/pages/EditItem/index.js
new file mode 100644
index 00000000..64678a82
--- /dev/null
+++ b/src/pages/EditItem/index.js
@@ -0,0 +1 @@
+export { default } from './EditItem';
diff --git a/src/pages/ProductDetail/ProductDetail.jsx b/src/pages/ProductDetail/ProductDetail.jsx
new file mode 100644
index 00000000..d78d5f81
--- /dev/null
+++ b/src/pages/ProductDetail/ProductDetail.jsx
@@ -0,0 +1,63 @@
+import { useEffect, useState } from 'react';
+import { useParams, Link } from 'react-router-dom';
+import { useToast } from '@/contexts';
+import { getProductDetail } from '@/api/product';
+import { CommentInput, CommentList } from '@/components/Comment';
+import { ProductInfo } from '@/components/Product';
+import { safeFetch } from '@/utils/api';
+import { getProductErrorMessage } from '@/utils/errorMessage/productErrorMessage';
+import { baseUrl, ENDPOINTS, ROUTES } from '@/constants/urls';
+import arrowBackIcon from '@/assets/icons/arrow_back.svg';
+import commonStyles from '@/styles/helpers/commonHelpers.module.scss';
+import buttonStyles from '@/styles/helpers/buttonHelpers.module.scss';
+import styles from './ProductDetail.module.scss';
+
+const ProductDetail = () => {
+ const { showToast } = useToast();
+ console.log('useParams에 어떻게 출력되나', useParams());
+ const { productId } = useParams();
+ const [product, setProduct] = useState(undefined);
+ console.log('product', product);
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ useEffect(() => {
+ const fetchProduct = async () => {
+ try {
+ const data = await getProductDetail(productId);
+ setProduct(data);
+ } catch (error) {
+ showToast(getProductErrorMessage(error.status), 'error');
+ }
+ };
+
+ fetchProduct();
+ }, [productId]);
+
+ const refreshAfterSubmit = () => setRefreshKey((prev) => prev + 1);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ 목록으로 돌아가기
+

+
+
+
+ );
+};
+
+export default ProductDetail;
diff --git a/src/pages/ProductDetail/ProductDetail.module.scss b/src/pages/ProductDetail/ProductDetail.module.scss
new file mode 100644
index 00000000..d3fa8bcd
--- /dev/null
+++ b/src/pages/ProductDetail/ProductDetail.module.scss
@@ -0,0 +1,28 @@
+.productDetail {
+ display: flex;
+ flex-direction: column;
+ gap: 4rem;
+}
+
+.commentSection {
+ display: flex;
+ flex-direction: column;
+ gap: 2.4rem;
+}
+
+.backButtonWrapper {
+ margin-bottom: 2.4rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ .backButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.8rem;
+ font-size: 1.8rem;
+ padding: 1.1rem 4rem;
+ border-radius: 40px;
+ width: fit-content;
+ }
+}
diff --git a/src/pages/ProductDetail/index.js b/src/pages/ProductDetail/index.js
new file mode 100644
index 00000000..ff0e5da6
--- /dev/null
+++ b/src/pages/ProductDetail/index.js
@@ -0,0 +1 @@
+export { default } from './ProductDetail';
diff --git a/src/pages/index.js b/src/pages/index.js
index 216545e5..bfbae9e3 100644
--- a/src/pages/index.js
+++ b/src/pages/index.js
@@ -3,3 +3,5 @@ export { default as SignUp } from './SignUp';
export { default as SignIn } from './SignIn';
export { default as Items } from './Items';
export { default as AddItem } from './AddItem';
+export { default as ProductDetail } from './ProductDetail';
+export { default as EditItem} from './EditItem';
diff --git a/src/routes/index.jsx b/src/routes/index.jsx
index 37838232..2cd40761 100644
--- a/src/routes/index.jsx
+++ b/src/routes/index.jsx
@@ -1,17 +1,30 @@
import { Route, Routes } from 'react-router-dom';
-import { Landing, SignUp, SignIn, Items, AddItem } from '@/pages';
+import {
+ Landing,
+ SignUp,
+ SignIn,
+ Items,
+ AddItem,
+ ProductDetail,
+ EditItem,
+} from '@/pages';
import App from '@/App';
+import ROUTES from '@/constants/urls/routes';
const AppRoutes = () => (
- }>
+ }>
} />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
);
export default AppRoutes;
+
+// 노트: path=":productId"에서 ":productId"는 문자열이 아니라, "/items/무엇이든" 을 받되, 그 "무엇이든"을 productId라는 이름의 변수로 저장하는 역할
diff --git a/src/styles/common/base.css b/src/styles/common/base.css
index 5885e0e8..7aa1edb9 100644
--- a/src/styles/common/base.css
+++ b/src/styles/common/base.css
@@ -10,11 +10,6 @@ body {
color: var(--secondary-800);
}
-p {
- font-size: 1rem;
- line-height: 1.7;
-}
-
ul,
ol {
font-size: 1rem;
diff --git a/src/styles/common/reset.css b/src/styles/common/reset.css
index aac599b6..bd4e3440 100644
--- a/src/styles/common/reset.css
+++ b/src/styles/common/reset.css
@@ -15,6 +15,7 @@ ul,
ol,
label {
margin: 0;
+ font-size: inherit;
}
ul,
diff --git a/src/styles/helpers/buttonHelpers.module.scss b/src/styles/helpers/buttonHelpers.module.scss
index 0ce341b4..a2e9756a 100644
--- a/src/styles/helpers/buttonHelpers.module.scss
+++ b/src/styles/helpers/buttonHelpers.module.scss
@@ -6,6 +6,7 @@
border: none;
font-weight: 600;
transition: background-color 0.2s ease;
+ width: fit-content;
&:hover {
background-color: var(--primary-200);
diff --git a/src/styles/helpers/commonHelpers.module.scss b/src/styles/helpers/commonHelpers.module.scss
new file mode 100644
index 00000000..b1f172a3
--- /dev/null
+++ b/src/styles/helpers/commonHelpers.module.scss
@@ -0,0 +1,5 @@
+.horizontalLine {
+ width: 100%;
+ height: 1px;
+ background-color: var(--secondary-200);
+}
diff --git a/src/styles/helpers/tagHelpers.module.scss b/src/styles/helpers/tagHelpers.module.scss
new file mode 100644
index 00000000..6dd267d6
--- /dev/null
+++ b/src/styles/helpers/tagHelpers.module.scss
@@ -0,0 +1,17 @@
+.tagList {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.8rem;
+}
+
+.tag {
+ width: fit-content;
+ padding: 0.5rem 1.2rem 0.5rem 1.6rem;
+ background-color: var(--primary-50);
+ border-radius: 26px;
+ font-size: 1.6rem;
+ color: var(--secondary-800);
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+}
diff --git a/src/utils/api/safeFetch.js b/src/utils/api/safeFetch.js
index a86809aa..bec75a20 100644
--- a/src/utils/api/safeFetch.js
+++ b/src/utils/api/safeFetch.js
@@ -1,19 +1,13 @@
-import HTTP_STATUS from '@/constants/statusCodes';
import { HTTP_ERROR_MESSAGES } from '@/constants/messages';
-const safeFetch = async ({
- url,
- options,
- showToast,
- showToastOnError = true,
- uiErrorMessage = HTTP_ERROR_MESSAGES.UNKNOWN,
-}) => {
+const safeFetch = async ({ url, options }) => {
try {
const res = await fetch(url, options);
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
- const message = errorData.message || uiErrorMessage;
+ const message = errorData.message || HTTP_ERROR_MESSAGES.UNKNOWN;
+
const error = new Error(message);
error.status = res.status;
throw error;
@@ -21,37 +15,11 @@ const safeFetch = async ({
return await res.json();
} catch (error) {
- // 콘솔 에러: 개발자용
console.error(
`🔴 요청 에러 [${error.status ?? 'unknown'}]:`,
error.message,
);
- // 에러 메시지: 사용자용
- if (showToast && showToastOnError) {
- let toastMessage = '';
-
- switch (error.status) {
- case HTTP_STATUS.UNAUTHORIZED:
- toastMessage = HTTP_ERROR_MESSAGES.UNAUTHORIZED;
- break;
- case HTTP_STATUS.FORBIDDEN:
- toastMessage = HTTP_ERROR_MESSAGES.FORBIDDEN;
- break;
- case HTTP_STATUS.NOT_FOUND:
- toastMessage = HTTP_ERROR_MESSAGES.NOT_FOUND;
- break;
- default:
- if (error.status >= 500) {
- toastMessage = HTTP_ERROR_MESSAGES.SERVER_ERROR;
- } else {
- toastMessage = error.message || uiErrorMessage;
- }
- }
-
- showToast(toastMessage, 'error');
- }
-
throw error;
}
};
diff --git a/src/utils/errorMessage/authErrorMessage.js b/src/utils/errorMessage/authErrorMessage.js
new file mode 100644
index 00000000..efbea18e
--- /dev/null
+++ b/src/utils/errorMessage/authErrorMessage.js
@@ -0,0 +1 @@
+// 회원가입 로그인도 토큰 처리할때 에러메시지 하기
diff --git a/src/utils/errorMessage/commentErrorMessage.js b/src/utils/errorMessage/commentErrorMessage.js
new file mode 100644
index 00000000..512427d9
--- /dev/null
+++ b/src/utils/errorMessage/commentErrorMessage.js
@@ -0,0 +1,72 @@
+import HTTP_STATUS from '@/constants/statusCodes';
+import { COMMENT_ERROR_MESSAGES as M } from '@/constants/messages';
+
+export const getCommentErrorMessage = (status) => {
+ switch (status) {
+ case HTTP_STATUS.UNAUTHORIZED:
+ return M.UNAUTHORIZED;
+ case HTTP_STATUS.FORBIDDEN:
+ return M.FORBIDDEN_FETCH;
+ case HTTP_STATUS.NOT_FOUND:
+ return M.NOT_FOUND_FETCH;
+ case HTTP_STATUS.INTERNAL_SERVER_ERROR:
+ case 502:
+ case HTTP_STATUS.SERVICE_UNAVAILABLE:
+ case 504:
+ return M.SERVER_ERROR;
+ default:
+ return M.FETCH_FAILED;
+ }
+};
+
+export const postCommentErrorMessage = (status) => {
+ switch (status) {
+ case HTTP_STATUS.UNAUTHORIZED:
+ return M.UNAUTHORIZED;
+ case HTTP_STATUS.FORBIDDEN:
+ return M.FORBIDDEN_POST;
+ case HTTP_STATUS.INTERNAL_SERVER_ERROR:
+ case 502:
+ case HTTP_STATUS.SERVICE_UNAVAILABLE:
+ case 504:
+ return M.SERVER_ERROR;
+ default:
+ return M.POST_FAILED;
+ }
+};
+
+export const patchCommentErrorMessage = (status) => {
+ switch (status) {
+ case HTTP_STATUS.UNAUTHORIZED:
+ return M.UNAUTHORIZED;
+ case HTTP_STATUS.FORBIDDEN:
+ return M.FORBIDDEN_PATCH;
+ case HTTP_STATUS.NOT_FOUND:
+ return M.NOT_FOUND_PATCH;
+ case HTTP_STATUS.INTERNAL_SERVER_ERROR:
+ case 502:
+ case HTTP_STATUS.SERVICE_UNAVAILABLE:
+ case 504:
+ return M.SERVER_ERROR;
+ default:
+ return M.PATCH_FAILED;
+ }
+};
+
+export const deleteCommentErrorMessage = (status) => {
+ switch (status) {
+ case HTTP_STATUS.UNAUTHORIZED:
+ return M.UNAUTHORIZED;
+ case HTTP_STATUS.FORBIDDEN:
+ return M.FORBIDDEN_DELETE;
+ case HTTP_STATUS.NOT_FOUND:
+ return M.NOT_FOUND_DELETE;
+ case HTTP_STATUS.INTERNAL_SERVER_ERROR:
+ case 502:
+ case HTTP_STATUS.SERVICE_UNAVAILABLE:
+ case 504:
+ return M.SERVER_ERROR;
+ default:
+ return M.DELETE_FAILED;
+ }
+};
diff --git a/src/utils/errorMessage/index.js b/src/utils/errorMessage/index.js
new file mode 100644
index 00000000..3a9c46e1
--- /dev/null
+++ b/src/utils/errorMessage/index.js
@@ -0,0 +1,12 @@
+export {
+ getProductErrorMessage,
+ postProductErrorMessage,
+ patchProductErrorMessage,
+ deleteProductErrorMessage,
+} from './productErrorMessage';
+export {
+ getCommentErrorMessage,
+ postCommentErrorMessage,
+ patchCommentErrorMessage,
+ deleteCommentErrorMessage,
+} from './commentErrorMessage';
diff --git a/src/utils/errorMessage/productErrorMessage.js b/src/utils/errorMessage/productErrorMessage.js
new file mode 100644
index 00000000..8f832074
--- /dev/null
+++ b/src/utils/errorMessage/productErrorMessage.js
@@ -0,0 +1,72 @@
+import HTTP_STATUS from '@/constants/statusCodes';
+import { PRODUCT_ERROR_MESSAGES as M } from '@/constants/messages';
+
+export const getProductErrorMessage = (status) => {
+ switch (status) {
+ case HTTP_STATUS.UNAUTHORIZED:
+ return M.UNAUTHORIZED;
+ case HTTP_STATUS.FORBIDDEN:
+ return M.FORBIDDEN_FETCH;
+ case HTTP_STATUS.NOT_FOUND:
+ return M.NOT_FOUND_FETCH;
+ case HTTP_STATUS.INTERNAL_SERVER_ERROR:
+ case 502:
+ case HTTP_STATUS.SERVICE_UNAVAILABLE:
+ case 504:
+ return M.SERVER_ERROR;
+ default:
+ return M.FETCH_FAILED;
+ }
+};
+
+export const postProductErrorMessage = (status) => {
+ switch (status) {
+ case HTTP_STATUS.UNAUTHORIZED:
+ return M.UNAUTHORIZED;
+ case HTTP_STATUS.FORBIDDEN:
+ return M.FORBIDDEN_POST;
+ case HTTP_STATUS.INTERNAL_SERVER_ERROR:
+ case 502:
+ case HTTP_STATUS.SERVICE_UNAVAILABLE:
+ case 504:
+ return M.SERVER_ERROR;
+ default:
+ return M.POST_FAILED;
+ }
+};
+
+export const patchProductErrorMessage = (status) => {
+ switch (status) {
+ case HTTP_STATUS.UNAUTHORIZED:
+ return M.UNAUTHORIZED;
+ case HTTP_STATUS.FORBIDDEN:
+ return M.FORBIDDEN_PATCH;
+ case HTTP_STATUS.NOT_FOUND:
+ return M.NOT_FOUND_PATCH;
+ case HTTP_STATUS.INTERNAL_SERVER_ERROR:
+ case 502:
+ case HTTP_STATUS.SERVICE_UNAVAILABLE:
+ case 504:
+ return M.SERVER_ERROR;
+ default:
+ return M.PATCH_FAILED;
+ }
+};
+
+export const deleteProductErrorMessage = (status) => {
+ switch (status) {
+ case HTTP_STATUS.UNAUTHORIZED:
+ return M.UNAUTHORIZED;
+ case HTTP_STATUS.FORBIDDEN:
+ return M.FORBIDDEN_DELETE;
+ case HTTP_STATUS.NOT_FOUND:
+ return M.NOT_FOUND_DELETE;
+ case HTTP_STATUS.INTERNAL_SERVER_ERROR:
+ case 502:
+ case HTTP_STATUS.SERVICE_UNAVAILABLE:
+ case 504:
+ return M.SERVER_ERROR;
+ default:
+ return M.DELETE_FAILED;
+ }
+};
diff --git a/src/utils/format/date.js b/src/utils/format/date.js
new file mode 100644
index 00000000..2e6ced5b
--- /dev/null
+++ b/src/utils/format/date.js
@@ -0,0 +1,7 @@
+export const isoDate = (isoDate) => {
+ return new Date(isoDate).toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ });
+};
diff --git a/src/utils/format/index.js b/src/utils/format/index.js
new file mode 100644
index 00000000..1a278442
--- /dev/null
+++ b/src/utils/format/index.js
@@ -0,0 +1,2 @@
+export { isoDate } from './date';
+export { relativeTime } from './time';
diff --git a/src/utils/format/time.js b/src/utils/format/time.js
new file mode 100644
index 00000000..1dff70fa
--- /dev/null
+++ b/src/utils/format/time.js
@@ -0,0 +1,17 @@
+export const relativeTime = (isoDate) => {
+ const now = new Date();
+ const date = new Date(isoDate);
+ const diff = (now - date) / 1000;
+
+ const rtf = new Intl.RelativeTimeFormat('ko', { numeric: 'auto' });
+
+ if (diff < 60) return rtf.format(-Math.floor(diff), 'second');
+ if (diff < 3600) return rtf.format(-Math.floor(diff / 60), 'minute');
+ if (diff < 86400) return rtf.format(-Math.floor(diff / 3600), 'hour');
+ if (diff < 604800) return rtf.format(-Math.floor(diff / 86400), 'day');
+ // return date.toLocaleDateString('ko-KR'); // 일주일 이상은 그냥 날짜로
+
+ if (diff < 2592000) return rtf.format(-Math.floor(diff / 604800), 'week');
+ if (diff < 31536000) return rtf.format(-Math.floor(diff / 2592000), 'month');
+ return rtf.format(-Math.floor(diff / 31536000), 'year');
+};
diff --git a/vite.config.js b/vite.config.js
index c663f915..b6c836a0 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -8,7 +8,7 @@ const __dirname = path.dirname(__filename);
export default defineConfig({
plugins: [react()],
- base: './',
+ base: '/',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),