Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
946c505
feat: add product detail route
sgoldenbird May 11, 2025
b339454
feat: create DropdownBtn component
sgoldenbird May 11, 2025
6b10a4a
refactor: change SortDrop using DropdownBtn
sgoldenbird May 11, 2025
59b36d0
feat: separate wrapper width into icon mode and text mode
sgoldenbird May 11, 2025
2748f3d
feat: create tagHelpers.module.scss
sgoldenbird May 11, 2025
91cd7d9
feat: create ProductInfo component
sgoldenbird May 11, 2025
bd6ae14
style: make ProductInfo component responsive
sgoldenbird May 11, 2025
f1b506b
feat: create CommentInput component
sgoldenbird May 11, 2025
2e20c73
feat: create CommentList component
sgoldenbird May 11, 2025
f8d925a
feat: create ProductDetail page
sgoldenbird May 11, 2025
854d791
chore: add _redirect
sgoldenbird May 11, 2025
d26cd45
fix: add Netlify SPA redirect config via netlify.toml
sgoldenbird May 11, 2025
9c8144c
fix: correct Vite base path for Netlify deployment
sgoldenbird May 11, 2025
2b44253
feat: create no comment list UI
sgoldenbird May 12, 2025
bf1c573
feat: create CommentItem component and edit, delete comment logic
sgoldenbird May 12, 2025
f2370c6
feat: create UserContext
sgoldenbird May 12, 2025
cb173de
refactor: remove Toast on safe fetch function
sgoldenbird May 12, 2025
15e1ef4
feat: split error message handlers by HTTP method and resource type
sgoldenbird May 12, 2025
c200e81
refactor: apply status-based error messages to all product-related fe…
sgoldenbird May 12, 2025
93b1449
feat: navigate to product detail page after successful add item
sgoldenbird May 12, 2025
c0acf35
refactor: apply constants to error messages
sgoldenbird May 12, 2025
e52afed
refactor: modularize fetch logic
sgoldenbird May 12, 2025
3789f87
fix: reset file input when removing image to allow re-upload of same …
sgoldenbird May 12, 2025
aa4e70a
feat: Added delete and update logic to product info
sgoldenbird May 12, 2025
4585d11
fix: receive id prop correctly
sgoldenbird May 12, 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
85 changes: 68 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/) | 상품 등록 페이지 구현 |
<table>
<thead>
<tr>
<th>미션</th>
<th>날짜</th>
<th>PR</th>
<th>주요 내용</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>2025-02-24</td>
<td><a href="https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/10">#10</a></td>
<td>랜딩 페이지의 HTML 및 CSS 구현</td>
</tr>
<tr>
<td>2</td>
<td>2025-03-05</td>
<td><a href="https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/44">#44</a></td>
<td>회원가입 및 로그인 페이지의 HTML, CSS 구현</td>
</tr>
<tr>
<td>3</td>
<td>2025-03-07</td>
<td><a href="https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/60">#60</a></td>
<td>반응형 디자인 구현(desktop-first, 1920px 이상 큰 모니터 기준), breakpoint: 1919px, 1199px, 767px</td>
</tr>
<tr>
<td>4</td>
<td>2025-03-18</td>
<td><a href="https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/101">#101</a></td>
<td>JS기능 추가(DOM 요소 조작 및 이벤트 리스너), 회원가입, 로그인 폼 유효성 검사</td>
</tr>
<tr>
<td>5</td>
<td>2025-05-05</td>
<td><a href="https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/181">#181</a></td>
<td>React, SCSS+CSS modules로 마이그레이션, items 페이지 구현(fetch data, 검색어, 정렬, pagination, 반응형 구현)</td>
</tr>
<tr>
<td>6</td>
<td>2025-05-11</td>
<td><a href="https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/203">#203</a></td>
<td>상품 등록 페이지 구현, 토스트 생성, 에러 처리 로직을 safeFetch 함수로 분리, UI 에러 메시지 상수화</td>
</tr>
<tr>
<td>7</td>
<td>2025-05-11</td>
<td><a href="https://github.com/codeit-bootcamp-frontend/15-Sprint-Mission/pull/">#</a></td>
<td>상품 상세 페이지 구현</td>
</tr>
</tbody>
</table>

---

Expand Down Expand Up @@ -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 처리 예정
4 changes: 4 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
1 change: 1 addition & 0 deletions public/_redirects
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/* /index.html 200
32 changes: 32 additions & 0 deletions src/api/comment.js
Original file line number Diff line number Diff line change
@@ -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);
};
50 changes: 50 additions & 0 deletions src/api/product.js
Original file line number Diff line number Diff line change
@@ -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);
};
47 changes: 47 additions & 0 deletions src/api/request.js
Original file line number Diff line number Diff line change
@@ -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 } });
};
4 changes: 4 additions & 0 deletions src/assets/icons/arrow_back.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 src/assets/icons/vertical_kebab.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion src/components/AddItem/ImageUploader/ImageUploader.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,6 +12,8 @@ const ImageUploader = ({
showImageWarning,
setShowImageWarning,
}) => {
const fileInputRef = useRef(null);

useEffect(() => {
return () => {
if (imagePreview && imagePreview.startsWith('blob:')) {
Expand Down Expand Up @@ -47,6 +49,11 @@ const ImageUploader = ({

// formData에서 imageFile도 제거
handleInputChange({ field: 'imageFile', value: null });

// 파일 input의 value도 초기화
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};

return (
Expand All @@ -65,6 +72,7 @@ const ImageUploader = ({
onChange={handleImageChange}
hidden
disabled={!!imagePreview}
ref={fileInputRef}
/>
</label>

Expand Down
3 changes: 2 additions & 1 deletion src/components/AddItem/TagInput/TagInput.jsx
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -39,7 +40,7 @@ const TagInput = ({ tagInput, setTagInput, tags, handleInputChange }) => {
</div>
<div className={styles.tagList}>
{tags.map((tag) => (
<div key={tag} className={styles.tag}>
<div key={tag} className={tagStyles.tag}>
#{tag}
<RemoveIcon
onClick={() => handleRemoveTag(tag)}
Expand Down
25 changes: 13 additions & 12 deletions src/components/AddItem/TagInput/TagInput.module.scss
Original file line number Diff line number Diff line change
@@ -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;
// }
54 changes: 54 additions & 0 deletions src/components/Comment/CommentInput/CommentInput.jsx
Original file line number Diff line number Diff line change
@@ -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 }) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

💊 제안
CommentInput 컴포넌트 내부에서 refreshAfterSubmit라는 이름으로 함수를 받을 필요가 없어보여요.
이러한 이름은 함수명으로는 적절하지만 해당 컴포넌트 내부에서는 그냥 onSubmit 정도로 추상화하는게 더 적절해보입니다~

const { showToast } = useToast();
const [content, setContent] = useState('');

const handleSubmit = async () => {
if (!content.trim()) return;
Comment on lines +13 to +14
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
const handleSubmit = async () => {
if (!content.trim()) return;
const handleSubmit = async () => {


try {
await postComment(productId, content);

setContent('');
refreshAfterSubmit(); // 등록 후 댓글 목록 새로고침
} catch (error) {
showToast(postCommentErrorMessage(error.status), 'error');
}
};

return (
<div className={formStyles.inputContainer}>
<div className={styles.inputContainer}>
<label htmlFor="content" className={formStyles.labelText}>
문의하기
</label>
<textarea
id="content"
name="content"
value={content}
className={formStyles.textarea}
rows={4}
onChange={(e) => setContent(e.target.value)}
placeholder="개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다."
/>
</div>
<button
type="button"
className={`${buttonStyles.primary} ${styles.submitButton}`}
onClick={handleSubmit}
disabled={!content.trim()}
>
등록
</button>
</div>
);
};

export default CommentInput;
Loading
Loading