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/) | 상품 등록 페이지 구현 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
미션날짜PR주요 내용
12025-02-24#10랜딩 페이지의 HTML 및 CSS 구현
22025-03-05#44회원가입 및 로그인 페이지의 HTML, CSS 구현
32025-03-07#60반응형 디자인 구현(desktop-first, 1920px 이상 큰 모니터 기준), breakpoint: 1919px, 1199px, 767px
42025-03-18#101JS기능 추가(DOM 요소 조작 및 이벤트 리스너), 회원가입, 로그인 폼 유효성 검사
52025-05-05#181React, SCSS+CSS modules로 마이그레이션, items 페이지 구현(fetch data, 검색어, 정렬, pagination, 반응형 구현)
62025-05-11#203상품 등록 페이지 구현, 토스트 생성, 에러 처리 로직을 safeFetch 함수로 분리, UI 에러 메시지 상수화
72025-05-11#상품 상세 페이지 구현
--- @@ -85,21 +131,26 @@ ``` ## 에러 처리 전략 + > 모든 에러는 사용자에게 UX 혼란을 최소화하기 위한 피드백(UI/토스트 등)을 포함하여 처리됩니다. ### 1. 라우팅 오류 -- 잘못된 경로 접근 시 → `404 페이지` → 랜딩 페이지로 이동 버튼 + +- 잘못된 경로 접근 시 → `404 페이지` → 랜딩 페이지로 이동 버튼 ### 2. 전역 에러 (App 깨짐) -- 앱 전체 서버 에러 → `500 페이지` → 다시 시도 버튼 + +- 앱 전체 서버 에러 → `500 페이지` → 다시 시도 버튼 ### 3. API 응답 에러 (safeFetch 내부 → 토스트 처리 ) -| 상태 코드 | 처리 방식 | -|-----------|-----------| -| `401` | 인증 필요 안내 토스트 | -| `403` | 접근 권한 없음 안내 토스트 | -| `404` | 없는 리소스 조회 시 토스트 | + +| 상태 코드 | 처리 방식 | +| --------- | -------------------------- | +| `401` | 인증 필요 안내 토스트 | +| `403` | 접근 권한 없음 안내 토스트 | +| `404` | 없는 리소스 조회 시 토스트 | | `500~599` | 서버 응답 오류 토스트 노출 | -### 4. 특정 컴포넌트 렌더 실패 (예: 이미지 리스트 하나가 깨짐) -- 해당 컴포넌트 수준에서 fallback UI 처리 예정 +### 4. 특정 컴포넌트 렌더 실패 (예: 이미지 리스트 하나가 깨짐) + +- 해당 컴포넌트 수준에서 fallback UI 처리 예정 diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 00000000..ff1c0508 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,4 @@ +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 \ No newline at end of file diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 00000000..50a46335 --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/src/api/comment.js b/src/api/comment.js new file mode 100644 index 00000000..f8065931 --- /dev/null +++ b/src/api/comment.js @@ -0,0 +1,32 @@ +import { baseUrl, ENDPOINTS } from '@/constants/urls'; +import { + requestPost, + requestGet, + requestDelete, + requestPatch, +} from './request'; + +// 댓글 등록 +export const postComment = (productId, content) => { + const url = `${baseUrl}${ENDPOINTS.PRODUCTS}/${productId}${ENDPOINTS.COMMENTS}`; + return requestPost(url, { content }); +}; + +// 댓글 조회 +export const getComments = (productId, cursor) => { + const query = cursor ? `?limit=10&cursor=${cursor}` : '?limit=10'; + const url = `${baseUrl}${ENDPOINTS.PRODUCTS}/${productId}${ENDPOINTS.COMMENTS}${query}`; + return requestGet(url); +}; + +// 댓글 수정 +export const patchComment = (commentId, content) => { + const url = `${baseUrl}${ENDPOINTS.COMMENTS}/${commentId}`; + return requestPatch(url, { content }); +}; + +// 댓글 삭제 +export const deleteComment = (commentId) => { + const url = `${baseUrl}${ENDPOINTS.COMMENTS}/${commentId}`; + return requestDelete(url); +}; diff --git a/src/api/product.js b/src/api/product.js new file mode 100644 index 00000000..11e20148 --- /dev/null +++ b/src/api/product.js @@ -0,0 +1,50 @@ +import { baseUrl, ENDPOINTS } from '@/constants/urls'; +import { + requestPost, + requestGet, + requestPatch, + requestDelete, +} from './request'; + +// 상품 등록 +export const postProduct = (formData) => { + const url = `${baseUrl}${ENDPOINTS.PRODUCTS}`; + return requestPost(url, formData); +}; + +// 이미지 업로드 (FormData) +export const uploadImage = (formData) => { + const url = `${baseUrl}${ENDPOINTS.UPLOAD_IMAGE}`; + return requestPost(url, formData); +}; + +// 상품 전체 조회 +export const fetchAllProducts = ({ page, pageSize, orderBy, keyword }) => { + const query = `?page=${page}&pageSize=${pageSize}&orderBy=${orderBy}&keyword=${encodeURIComponent(keyword || '')}`; + const url = `${baseUrl}${ENDPOINTS.PRODUCTS}${query}`; + return requestGet(url); +}; + +// 베스트 top4 상품 조회 +export const fetchBestProducts = () => { + const url = `${baseUrl}${ENDPOINTS.PRODUCTS}?page=1&pageSize=4&orderBy=favorite`; + return requestGet(url); +}; + +// 상품 상세 조회 +export const getProductDetail = (productId) => { + const url = `${baseUrl}${ENDPOINTS.PRODUCTS}/${productId}`; + return requestGet(url); +}; + +// 상품 수정 +export const patchProduct = (productId, data) => { + const url = `${baseUrl}${ENDPOINTS.PRODUCTS}/${productId}`; + return requestPatch(url, data); +}; + +// 상품 삭제 +export const deleteProduct = (productId) => { + const url = `${baseUrl}${ENDPOINTS.PRODUCTS}/${productId}`; + return requestDelete(url); +}; diff --git a/src/api/request.js b/src/api/request.js new file mode 100644 index 00000000..c7aa3984 --- /dev/null +++ b/src/api/request.js @@ -0,0 +1,47 @@ +import { safeFetch } from '@/utils/api'; + +export const requestGet = async (url, options = {}) => { + return safeFetch({ url, options: { method: 'GET', ...options } }); +}; + +export const requestPost = async (url, data, options = {}) => { + const isFormData = data instanceof FormData; + + return safeFetch({ + url, + options: { + method: 'POST', + headers: isFormData ? undefined : { 'Content-Type': 'application/json' }, + body: isFormData ? data : JSON.stringify(data), + ...options, + }, + }); +}; + +export const requestPut = async (url, data, options = {}) => { + return safeFetch({ + url, + options: { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + ...options, + }, + }); +}; + +export const requestPatch = async (url, data, options = {}) => { + return safeFetch({ + url, + options: { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + ...options, + }, + }); +}; + +export const requestDelete = async (url, options = {}) => { + return safeFetch({ url, options: { method: 'DELETE', ...options } }); +}; diff --git a/src/assets/icons/arrow_back.svg b/src/assets/icons/arrow_back.svg new file mode 100644 index 00000000..9ba5ad94 --- /dev/null +++ b/src/assets/icons/arrow_back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/vertical_kebab.svg b/src/assets/icons/vertical_kebab.svg new file mode 100644 index 00000000..dd7ed7f5 --- /dev/null +++ b/src/assets/icons/vertical_kebab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/dphoto@3x.svg b/src/assets/images/no_inquiries.svg similarity index 100% rename from src/assets/images/dphoto@3x.svg rename to src/assets/images/no_inquiries.svg diff --git a/src/components/AddItem/ImageUploader/ImageUploader.jsx b/src/components/AddItem/ImageUploader/ImageUploader.jsx index 0bcd84e0..0a751db3 100644 --- a/src/components/AddItem/ImageUploader/ImageUploader.jsx +++ b/src/components/AddItem/ImageUploader/ImageUploader.jsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { RemoveIcon } from '@/components/common/Buttons'; import { PRODUCT_INFO_MESSAGES } from '@/constants/messages'; import plusIcon from '@/assets/icons/plus.svg'; @@ -12,6 +12,8 @@ const ImageUploader = ({ showImageWarning, setShowImageWarning, }) => { + const fileInputRef = useRef(null); + useEffect(() => { return () => { if (imagePreview && imagePreview.startsWith('blob:')) { @@ -47,6 +49,11 @@ const ImageUploader = ({ // formData에서 imageFile도 제거 handleInputChange({ field: 'imageFile', value: null }); + + // 파일 input의 value도 초기화 + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } }; return ( @@ -65,6 +72,7 @@ const ImageUploader = ({ onChange={handleImageChange} hidden disabled={!!imagePreview} + ref={fileInputRef} /> diff --git a/src/components/AddItem/TagInput/TagInput.jsx b/src/components/AddItem/TagInput/TagInput.jsx index 965494f3..9b0e939a 100644 --- a/src/components/AddItem/TagInput/TagInput.jsx +++ b/src/components/AddItem/TagInput/TagInput.jsx @@ -1,5 +1,6 @@ import { RemoveIcon } from '@/components/common/Buttons'; import formStyles from '@/styles/helpers/formHelpers.module.scss'; +import tagStyles from '@/styles/helpers/tagHelpers.module.scss'; import styles from './TagInput.module.scss'; const TagInput = ({ tagInput, setTagInput, tags, handleInputChange }) => { @@ -39,7 +40,7 @@ const TagInput = ({ tagInput, setTagInput, tags, handleInputChange }) => {
{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 ( +
+
+ +