diff --git a/package-lock.json b/package-lock.json
index 4eac7311..9a967c67 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2040,31 +2040,12 @@
"url": "https://opencollective.com/eslint"
}
},
-<<<<<<< HEAD
"node_modules/esquery": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"dev": true,
"license": "BSD-3-Clause",
-=======
- "node_modules/esprima": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
- "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "bin": {
- "esparse": "bin/esparse.js",
- "esvalidate": "bin/esvalidate.js"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/esquery": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
- "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
->>>>>>> 66f3ba6e6e7435aa827db8a2a9ce4b80c9b9d177
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -2076,11 +2057,8 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
-<<<<<<< HEAD
"dev": true,
"license": "BSD-2-Clause",
-=======
->>>>>>> 66f3ba6e6e7435aa827db8a2a9ce4b80c9b9d177
"dependencies": {
"estraverse": "^5.2.0"
},
@@ -3298,4 +3276,4 @@
}
}
}
-
+}
diff --git a/src/App.jsx b/src/App.jsx
index ed3f3226..92608742 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,48 +1,61 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import GlobalStyle from './styles/GlobalStyle';
import { ThemeProvider } from 'styled-components';
+import { AuthProvider } from './context/AuthContext';
import theme from './styles/theme';
import Header from './components/Layout/Header';
import HomePage from './components/pages/HomePage';
import LoginPage from './components/pages/LoginPage';
+import SignupPage from './components/pages/SignupPage';
import MarketPage from './components/pages/MarketPage/MarketPage';
import AddItemPage from './components/pages/AddItemPage/AddItemPage';
import CommunityFeedPage from './components/pages/CommunityFeedPage';
+import ItemDetailPage from './components/pages/ItemDetailPage/ItemDetailPage';
function App() {
return (
<>
-
-
-
-
+
+
+
+
+
-
-
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
-
-
-
-
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
>
);
}
diff --git a/src/api/api.js b/src/api/api.js
index 7a7b0aec..3b72e59f 100644
--- a/src/api/api.js
+++ b/src/api/api.js
@@ -13,3 +13,112 @@ export const getProducts = async ({ page = 1, pageSize = 10, orderBy = 'recent',
throw error;
}
};
+
+export const getProductDetail = async (productId) => {
+ try {
+ const res = await axios.get(`${baseURL}/products/${productId}`);
+ return res.data;
+ } catch (error) {
+ console.log('상품 상세 정보 api 호출 실패 :', error.message);
+ throw error;
+ }
+};
+
+export const postProduct = async (product) => {
+ try {
+ const token = localStorage.getItem('token');
+ const res = await axios.post(`${baseURL}/products`, product, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ });
+ return res.data;
+ } catch (error) {
+ console.log('상품 등록 api 호출 실패 :', error.message);
+ throw error;
+ }
+};
+
+export const postComment = async (productId, content) => {
+ try {
+ const token = localStorage.getItem('token');
+ const res = await axios.post(
+ `${baseURL}/products/${productId}/comments`,
+ { content: content },
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ }
+ );
+ return res.data;
+ } catch (error) {
+ console.log('상품 댓글 등록 api 호출 실패 :', error.message);
+ throw error;
+ }
+};
+
+export const getComments = async (productId, limit = 10, cursor = null) => {
+ try {
+ const res = await axios.get(`${baseURL}/products/${productId}/comments`, {
+ params: {
+ limit,
+ cursor,
+ },
+ });
+ return res.data;
+ } catch (error) {
+ console.log('상품 댓글 목록 api 호출 실패 :', error.message);
+ throw error;
+ }
+};
+
+export const patchComment = async (commentId, content) => {
+ try {
+ const res = await axios.patch(`${baseURL}/comments/${commentId}`, { content: content });
+ return res.data;
+ } catch (error) {
+ console.log('상품 댓글 수정 api 호출 실패 :', error.message);
+ throw error;
+ }
+};
+
+export const deleteComment = async (commentId) => {
+ try {
+ const res = await axios.delete(`${baseURL}/comments/${commentId}`);
+ return res.data;
+ } catch (error) {
+ console.log('상품 댓글 삭제 api 호출 실패 :', error.message);
+ throw error;
+ }
+};
+
+export const postSignup = async (email, password, nickname, passwordConfirmation) => {
+ try {
+ const res = await axios.post(`${baseURL}/auth/signup`, {
+ email,
+ password,
+ nickname,
+ passwordConfirmation,
+ });
+ return res.data;
+ } catch (error) {
+ console.log('회원가입 api 호출 실패 :', error.message);
+ throw error;
+ }
+};
+
+export const postLogin = async (email, password) => {
+ try {
+ const res = await axios.post(`${baseURL}/auth/signin`, {
+ email,
+ password,
+ });
+ return res.data;
+ } catch (error) {
+ console.log('로그인 api 호출 실패 :', error.message);
+ throw error;
+ }
+};
diff --git a/src/assets/images/icons/ic_back.png b/src/assets/images/icons/ic_back.png
new file mode 100644
index 00000000..6c5cbec2
Binary files /dev/null and b/src/assets/images/icons/ic_back.png differ
diff --git a/src/assets/images/icons/ic_google.png b/src/assets/images/icons/ic_google.png
new file mode 100644
index 00000000..53575dc0
Binary files /dev/null and b/src/assets/images/icons/ic_google.png differ
diff --git a/src/assets/images/icons/ic_kakao.png b/src/assets/images/icons/ic_kakao.png
new file mode 100644
index 00000000..000d07e3
Binary files /dev/null and b/src/assets/images/icons/ic_kakao.png differ
diff --git a/src/assets/images/icons/ic_kebab.svg b/src/assets/images/icons/ic_kebab.svg
new file mode 100644
index 00000000..dd7ed7f5
--- /dev/null
+++ b/src/assets/images/icons/ic_kebab.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/assets/images/icons/ic_nocomment.png b/src/assets/images/icons/ic_nocomment.png
new file mode 100644
index 00000000..25019b5a
Binary files /dev/null and b/src/assets/images/icons/ic_nocomment.png differ
diff --git a/src/assets/images/icons/ic_visibility_off.png b/src/assets/images/icons/ic_visibility_off.png
new file mode 100644
index 00000000..8e80ce22
Binary files /dev/null and b/src/assets/images/icons/ic_visibility_off.png differ
diff --git a/src/assets/images/icons/ic_visibility_on.png b/src/assets/images/icons/ic_visibility_on.png
new file mode 100644
index 00000000..9920a1ef
Binary files /dev/null and b/src/assets/images/icons/ic_visibility_on.png differ
diff --git a/src/components/Layout/Header.jsx b/src/components/Layout/Header.jsx
index 97abc131..42e44160 100644
--- a/src/components/Layout/Header.jsx
+++ b/src/components/Layout/Header.jsx
@@ -3,61 +3,75 @@ import styled from 'styled-components';
import { FontTypes, ColorTypes } from '../../styles/theme';
import { applyFontStyles } from '../../styles/mixins';
+import { useAuth } from '../../context/AuthContext';
import logo from '../../assets/images/logo/logo.svg';
import textLogo from '../../assets/images/logo/textlogo.svg';
import profile from '../../assets/images/icons/ic_profile.png';
function Header() {
+ const { isAuthenticated, logout } = useAuth();
const location = useLocation();
const isMarketActive = location.pathname === '/items' || location.pathname === '/additem';
return (
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+ {isAuthenticated ? (
+
+ 로그아웃
+
+
+
+
+ ) : (
+
+
+
+ )}
+
);
}
export default Header;
-const HeaderContainer = styled.header`
+const StyledHeaderContainer = styled.header`
display: flex;
justify-content: space-between;
align-items: center;
@@ -74,20 +88,20 @@ const HeaderContainer = styled.header`
}
`;
-const HeaderLeft = styled.div`
+const StyledHeaderLeft = styled.div`
display: flex;
align-items: center;
gap: 10px;
`;
-const Ul = styled.ul`
+const StyledUl = styled.ul`
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
`;
-const Li = styled.li`
+const StyledLi = styled.li`
${applyFontStyles(FontTypes.BOLD16, ColorTypes.SECONDARY_GRAY_600)};
&:hover {
@@ -95,7 +109,7 @@ const Li = styled.li`
}
`;
-const StNavLink = styled(NavLink)`
+const StyledNavLink = styled(NavLink)`
color: ${({ $isActive, theme }) =>
$isActive ? theme.colors[ColorTypes.PRIMARY_100] : theme.colors[ColorTypes.SECONDARY_GRAY_600]};
@@ -104,7 +118,7 @@ const StNavLink = styled(NavLink)`
}
`;
-const TextLogo = styled.img`
+const StyledTextLogo = styled.img`
width: 81px;
display: none;
@@ -113,7 +127,7 @@ const TextLogo = styled.img`
}
`;
-const Imglogo = styled.img`
+const StyledImglogo = styled.img`
width: 153px;
display: block;
@@ -121,3 +135,16 @@ const Imglogo = styled.img`
display: none;
}
`;
+
+const StyledLogoutButtonContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 16px;
+`;
+
+const StyledLogoutButton = styled.button`
+ padding: 10px 20px;
+ border-radius: 40px;
+ background-color: ${({ theme }) => theme.colors[ColorTypes.PRIMARY_100]};
+ ${applyFontStyles(FontTypes.SEMIBOLD14, ColorTypes.SECONDARY_WHITE)};
+`;
diff --git a/src/components/UI/CommentEditList.jsx b/src/components/UI/CommentEditList.jsx
new file mode 100644
index 00000000..6e465ac7
--- /dev/null
+++ b/src/components/UI/CommentEditList.jsx
@@ -0,0 +1,80 @@
+import styled from 'styled-components';
+import { useState } from 'react';
+
+import kebab from '../../assets/images/icons/ic_kebab.svg';
+import { FontTypes, ColorTypes } from '../../styles/theme';
+import { applyFontStyles } from '../../styles/mixins';
+
+function CommentEditList({ onEditClick, commentId, onDeleteClick }) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleEdit = () => {
+ onEditClick(commentId);
+ setIsOpen(false);
+ };
+
+ const handleDelete = () => {
+ onDeleteClick(commentId);
+ setIsOpen(false);
+ };
+
+ return (
+
+ setIsOpen((prev) => !prev)}>
+
+
+
+ {isOpen && (
+
+ 수정하기
+ 삭제하기
+
+ )}
+
+ );
+}
+
+export default CommentEditList;
+
+const StyledContainer = styled.div`
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+`;
+
+const StyledEditButton = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+`;
+
+const StyledEditList = styled.ul`
+ position: absolute;
+ top: 34px;
+ right: 0px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+
+ width: 138px;
+ height: 96px;
+ border-radius: 8px;
+ border: 1px solid ${({ theme }) => theme.colors[ColorTypes.SECONDARY_GRAY_200]};
+ background-color: #ffffff;
+ z-index: 1;
+`;
+
+const StyledEditItem = styled.li`
+ display: flex;
+ cursor: pointer;
+
+ ${applyFontStyles(FontTypes.REGULAR16, ColorTypes.SECONDARY_GRAY_500)}
+`;
diff --git a/src/components/UI/ImageUpload.jsx b/src/components/UI/ImageUpload.jsx
index dd1fb785..26c9d349 100644
--- a/src/components/UI/ImageUpload.jsx
+++ b/src/components/UI/ImageUpload.jsx
@@ -15,20 +15,21 @@ function ImageUpload() {
const file = e.target.files?.[0];
if (!file) return;
- const preview = URL.createObjectURL(file);
- setPreviewUrl(preview);
+ if (file) {
+ const preview = URL.createObjectURL(file);
+ setPreviewUrl(preview);
- if (previewUrl) {
- setError('*이미지 등록은 최대 1개까지 가능합니다.');
+ if (previewUrl) {
+ setError('*이미지 등록은 최대 1개까지 가능합니다.');
+ }
e.target.value = '';
return;
}
-
- setError('');
};
const handleImageRemove = () => {
setPreviewUrl(null);
+ setError('');
};
useEffect(() => {
@@ -40,19 +41,19 @@ function ImageUpload() {
}, [previewUrl]);
return (
-
+
-
-
+
-
-
+
+
이미지 등록
-
-
+
+
{previewUrl && (
-
-
+
-
-
+
)}
-
+
- {error && {error}}
-
+ {error && {error}}
+
);
}
export default ImageUpload;
-const Container = styled.div`
+const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
-const Wrapper = styled.div`
+const StyledWrapper = styled.div`
position: relative;
display: flex;
gap: 24px;
`;
-const ImageWrapper = styled.div`
+const StyledImageWrapper = styled.div`
display: flex;
`;
-const StInput = styled.input`
+const StyledInput = styled.input`
position: absolute;
opacity: 0;
width: 0;
@@ -111,7 +112,7 @@ const StInput = styled.input`
overflow: hidden;
`;
-const StLabel = styled.label`
+const StyledLabel = styled.label`
display: flex;
align-items: center;
justify-content: center;
@@ -131,22 +132,22 @@ const StLabel = styled.label`
}
`;
-const PreviewImage = styled.div`
+const StyledPreviewImage = styled.div`
position: relative;
`;
-const StImage = styled.img`
+const StyledImage = styled.img`
width: 168px;
height: 168px;
border-radius: 12px;
`;
-const StXIcon = styled.img`
+const StyledXIcon = styled.img`
position: absolute;
top: 14px;
right: 13px;
`;
-const ErrorMessage = styled.span`
+const StyledErrorMessage = styled.span`
${applyFontStyles(FontTypes.REGULAR16, ColorTypes.ERROR)}
`;
diff --git a/src/components/UI/InputField.jsx b/src/components/UI/InputField.jsx
index 648eb88e..28d9b71c 100644
--- a/src/components/UI/InputField.jsx
+++ b/src/components/UI/InputField.jsx
@@ -1,35 +1,109 @@
import styled from 'styled-components';
+import { useState } from 'react';
+import closeEye from '../../assets/images/icons/ic_visibility_off.png';
+import openEye from '../../assets/images/icons/ic_visibility_on.png';
+import { applyFontStyles } from '../../styles/mixins';
+import { ColorTypes, FontTypes } from '../../styles/theme';
-function InputField({ label, type, placeholder, isTextArea, value, onChange }) {
- return isTextArea ? (
-
-
-
-
- ) : (
-
+function InputField({ label, type, placeholder, isTextArea, value, onChange, onBlur, error, id }) {
+ const [isPwVisible, setIsPwVisible] = useState(false);
+
+ if (isTextArea) {
+ return (
+
+
+
+
+ );
+ }
+
+ const inputType = type === 'password' ? (isPwVisible ? 'text' : 'password') : type;
+
+ return (
+
-
-
+
+
+ {type === 'password' && (
+ setIsPwVisible((prev) => !prev)}
+ tabIndex={-1}
+ >
+
+
+ )}
+
+ {error && {error}}
+
);
}
export default InputField;
-const Container = styled.div`
+const StyledContainer = styled.div`
display: flex;
flex-direction: column;
- gap: 16px;
+ gap: 8px;
+ width: 100%;
+`;
+
+const InputWrapper = styled.div`
+ position: relative;
+ width: 100%;
+
+ input {
+ width: 100%;
+ padding-right: 30px;
+ border: 1px solid
+ ${({ $hasError, theme }) =>
+ $hasError ? theme.colors[ColorTypes.ERROR] : theme.colors[ColorTypes.SECONDARY_GRAY_100]};
+ }
+`;
+
+const EyeButton = styled.button`
+ position: absolute;
+ right: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+
+ img {
+ width: 20px;
+ height: 18px;
+ margin-right: 18px;
+ }
+`;
+const StyledError = styled.p`
+ ${applyFontStyles(FontTypes.SEMIBOLD14, ColorTypes.ERROR)}
`;
diff --git a/src/components/UI/SearchBar.jsx b/src/components/UI/SearchBar.jsx
index f5e9a034..ec0a3963 100644
--- a/src/components/UI/SearchBar.jsx
+++ b/src/components/UI/SearchBar.jsx
@@ -10,32 +10,32 @@ function SearchBar({ onSearch }) {
};
return (
-
-
-
+
+
-
-
-
+
+
);
}
export default SearchBar;
-const SearchBarContainer = styled.div`
+const StyledContainer = styled.div`
display: flex;
width: 100%;
`;
-const InputWrapper = styled.div`
+const StyledInputWrapper = styled.div`
position: relative;
- width:100%;
+ width: 100%;
flex-grow: 1;
height: 42px;
@@ -49,7 +49,7 @@ const InputWrapper = styled.div`
}
`;
-const StInput = styled.input`
+const StyledInput = styled.input`
width: 100%;
border-radius: 12px;
padding: 9px 0px 9px 44px;
@@ -61,7 +61,7 @@ const StInput = styled.input`
}
`;
-const StSearchIcon = styled.img`
+const StyledSearchIcon = styled.img`
position: absolute;
width: 24px;
height: 24px;
diff --git a/src/components/UI/Taginput.jsx b/src/components/UI/Taginput.jsx
index be1f2bd0..813639cb 100644
--- a/src/components/UI/Taginput.jsx
+++ b/src/components/UI/Taginput.jsx
@@ -26,10 +26,10 @@ function TagInput({ tags, setTags }) {
};
return (
-
+
-
+
setInputValue(e.target.value)}
/>
-
+
{tags.map((tag) => (
-
+
{tag}
handleTagRemove(tag)}
/>
-
+
))}
-
-
-
+
+
+
);
}
export default TagInput;
-const Container = styled.div`
+const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
-const Wrapper = styled.div`
+const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
gap: 14px;
`;
-const TagList = styled.div`
+export const StyledTagList = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px;
`;
-const TagWrapper = styled.div`
+export const StyledTagWrapper = styled.div`
display: flex;
justify-content: center;
gap: 10px;
diff --git a/src/components/pages/AddItemPage/AddItemPage.jsx b/src/components/pages/AddItemPage/AddItemPage.jsx
index 98cf9e96..113ddcef 100644
--- a/src/components/pages/AddItemPage/AddItemPage.jsx
+++ b/src/components/pages/AddItemPage/AddItemPage.jsx
@@ -1,33 +1,65 @@
import styled from 'styled-components';
import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import InputField from '../../UI/InputField';
import ImageUpload from '../../UI/ImageUpload';
import TagInput from '../../UI/Taginput';
import useFormatNumber from '../../../hooks/useFormatNumber';
+import { postProduct } from '../../../api/api';
import { ColorTypes } from '../../../styles/theme';
function AddItemPage() {
+ const navigate = useNavigate();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [price, handlePriceChange] = useFormatNumber('');
const [tags, setTags] = useState([]);
+ const [isSubmitting, setIsSubmitting] = useState(false);
- const isDisabled = name.trim() && description.trim() && price.trim() && tags.length > 0;
+ const isValid = name.trim() && description.trim() && price.replace(/,/g, '').length > 0 && tags.length > 0;
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (!isValid || isSubmitting) return;
+
+ setIsSubmitting(true);
+
+ try {
+ const productData = {
+ name: name.trim(),
+ description: description.trim(),
+ price: parseInt(price.replace(/,/g, '')),
+ tags: tags,
+ };
+
+ const response = await postProduct(productData);
+ console.log('상품 등록 성공:', response);
+
+ navigate('/items');
+ } catch (error) {
+ console.error('상품 등록 실패:', error);
+ alert('상품 등록에 실패했습니다. 다시 시도해주세요.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
return (
-
-
+
+
상품 등록하기
-
- 등록
-
-
+ {isSubmitting ? '등록 중...' : '등록'}
+
+
-
+
-
-
+
+
);
}
export default AddItemPage;
-const Container = styled.div`
+const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: 29px;
@@ -82,23 +114,29 @@ const Container = styled.div`
}
`;
-const HeaderSection = styled.div`
+const StyledHeaderSection = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
-const StButton = styled.button`
+const StyledButton = styled.button`
width: 74px;
height: 42px;
- background-color: ${({ theme }) => theme.colors[ColorTypes.SECONDARY_GRAY_400]};
+ background-color: ${({ theme, disabled }) =>
+ disabled ? theme.colors[ColorTypes.SECONDARY_GRAY_400] : theme.colors[ColorTypes.PRIMARY_100]};
&:disabled {
- background-color: ${({ theme }) => theme.colors[ColorTypes.PRIMARY_100]};
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+
+ &:not(:disabled):hover {
+ background-color: ${({ theme }) => theme.colors[ColorTypes.PRIMARY_200]};
}
`;
-const FormSection = styled.form`
+const StyledFormSection = styled.form`
display: flex;
flex-direction: column;
gap: 24px;
diff --git a/src/components/pages/ItemDetailPage/ItemDetailPage.jsx b/src/components/pages/ItemDetailPage/ItemDetailPage.jsx
new file mode 100644
index 00000000..52b5be21
--- /dev/null
+++ b/src/components/pages/ItemDetailPage/ItemDetailPage.jsx
@@ -0,0 +1,75 @@
+import styled from 'styled-components';
+import { Link } from 'react-router-dom';
+
+import ProductInfo from './ProductInfo';
+import ProductComments from './ProductComments';
+
+import { applyFontStyles } from '../../../styles/mixins';
+import { FontTypes, ColorTypes } from '../../../styles/theme';
+import back from '../../../assets/images/icons/ic_back.png';
+
+function ItemDetailPage() {
+ return (
+
+
+
+
+
+
+ 목록으로 돌아가기
+
+
+
+
+
+ );
+}
+
+export default ItemDetailPage;
+
+const StyledItemDetailPage = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ padding: 0 ${({ theme }) => theme.spacing.mobile};
+ margin-top: 24px;
+ margin-bottom: 100px;
+
+ @media (min-width: 768px) {
+ padding: 0 ${({ theme }) => theme.spacing.tablet};
+ }
+
+ @media (min-width: 1024px) {
+ padding: 0 ${({ theme }) => theme.spacing.desktop};
+ max-width: 1200px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+`;
+
+const StyledButtonWrapper = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+`;
+
+const StyledButton = styled.button`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+
+ width: 240px;
+ height: 48px;
+ padding: 12px 64px;
+ border-radius: 40px;
+`;
+
+const StyledButtonText = styled.p`
+ ${applyFontStyles(FontTypes.SEMIBOLD16, ColorTypes.SECONDARY_GRAY_100)}
+`;
diff --git a/src/components/pages/ItemDetailPage/ProductComments.jsx b/src/components/pages/ItemDetailPage/ProductComments.jsx
new file mode 100644
index 00000000..5628ba4c
--- /dev/null
+++ b/src/components/pages/ItemDetailPage/ProductComments.jsx
@@ -0,0 +1,421 @@
+import { useState, useEffect } from 'react';
+import { useParams } from 'react-router-dom';
+import styled from 'styled-components';
+
+import { getComments, postComment, patchComment, deleteComment } from '../../../api/api';
+import { applyFontStyles } from '../../../styles/mixins';
+import { FontTypes, ColorTypes } from '../../../styles/theme';
+import { applyFlexColumn } from '../../../styles/mixins';
+import useFormatTime from '../../../hooks/useFormatTime';
+import { StyledPagination, StyledCircle } from '../MarketPage/AllProducts';
+
+import profile from '../../../assets/images/icons/ic_profile.png';
+import CommentEditList from '../../UI/CommentEditList';
+import noComment from '../../../assets/images/icons/ic_nocomment.png';
+import left from '../../../assets/images/icons/arrow_left.svg';
+import right from '../../../assets/images/icons/arrow_right.svg';
+
+function ProductComments() {
+ const { productId } = useParams();
+ const [isNoComment, setIsNoComment] = useState(true);
+ const [comment, setComment] = useState('');
+ const [editCommentId, setEditCommentId] = useState(null);
+ const [editContent, setEditContent] = useState('');
+
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalCount, setTotalCount] = useState(0);
+ const [pageSize] = useState(3);
+ const [comments, setComments] = useState([]);
+ const [displayedComments, setDisplayedComments] = useState([]);
+
+ const submitComment = async (content) => {
+ try {
+ const data = await postComment(productId, content);
+ setComments((prev) => [data, ...prev]);
+ setComment('');
+ } catch (error) {
+ console.error('댓글 등록 실패:', error);
+ }
+ };
+
+ const fetchAllComments = async () => {
+ try {
+ const data = await getComments(productId, 100, null);
+ setIsNoComment(data.list.length === 0);
+ setComments(data.list);
+ setTotalCount(data.list.length);
+ } catch (error) {
+ console.error('댓글 목록 가져오기 실패:', error);
+ }
+ };
+
+ const updateDisplayedComments = (page) => {
+ const startIndex = (page - 1) * pageSize;
+ const endIndex = startIndex + pageSize;
+ const pageComments = comments.slice(startIndex, endIndex);
+ setDisplayedComments(pageComments);
+ };
+
+ const editComment = async (commentId, content) => {
+ try {
+ await patchComment(commentId, content);
+ } catch (error) {
+ console.error('댓글 수정 실패:', error);
+ }
+ };
+
+ const removeComment = async (commentId) => {
+ try {
+ await deleteComment(commentId);
+ } catch (error) {
+ console.error('댓글 삭제 실패:', error);
+ }
+ };
+
+ useEffect(() => {
+ setCurrentPage(1);
+ fetchAllComments();
+ }, [productId]);
+
+ useEffect(() => {
+ updateDisplayedComments(currentPage);
+ }, [currentPage, comments]);
+
+ const handlePostComment = (content) => {
+ if (!content.trim()) {
+ alert('댓글을 입력해주세요.');
+ return;
+ }
+ submitComment(content);
+ setCurrentPage(1);
+ fetchAllComments();
+ };
+
+ const handleEditClick = (commentId) => {
+ setEditCommentId(commentId);
+ const updateComment = comments.find((comment) => comment.id === commentId);
+ setEditContent(updateComment?.content || '');
+ };
+
+ const handleCancelEdit = () => {
+ setEditCommentId(null);
+ setEditContent('');
+ };
+
+ const handleSaveEdit = async (commentId, content) => {
+ try {
+ await editComment(commentId, content);
+ setEditCommentId(null);
+ setEditContent('');
+ setComments((prev) =>
+ prev.map((comment) => (comment.id === commentId ? { ...comment, content: content } : comment))
+ );
+ } catch (error) {
+ console.error('댓글 수정 실패:', error);
+ }
+ };
+
+ const handleDeleteClick = (commentId) => {
+ removeComment(commentId);
+ setComments((prev) => prev.filter((comment) => comment.id !== commentId));
+ };
+
+ const totalPages = Math.ceil(totalCount / pageSize);
+ const visiblePageCount = 5;
+ const safeCurrentPage = Math.max(currentPage, 1);
+ const currentGroup = Math.floor((safeCurrentPage - 1) / visiblePageCount);
+ const startPage = currentGroup * visiblePageCount + 1;
+ const endPage = Math.min(startPage + visiblePageCount - 1, totalPages);
+
+ const pageNumbers = [];
+ for (let i = startPage; i <= endPage; i++) {
+ pageNumbers.push(i);
+ }
+
+ return (
+
+
+ 문의하기
+
+
+ {isNoComment ? (
+
+
+ 아직 문의가 없어요
+
+ ) : (
+
+ {displayedComments?.map((comment) => {
+ const time = useFormatTime(comment?.createdAt || '');
+ return editCommentId === comment.id ? (
+
+
+
+
+ ) : (
+
+
+ {comment.content}
+
+
+
+
+
+
+
+
+
+
+ {comment.writer.nickname}
+ {time}
+
+
+
+
+ );
+ })}
+ {totalPages > 1 && (
+
+ currentPage > 1 && setCurrentPage(currentPage - 1)}
+ $disabled={currentPage <= 1}
+ >
+
+
+ {pageNumbers.map((pageNum) => (
+ setCurrentPage(pageNum)}
+ $isActive={pageNum === currentPage}
+ >
+ {pageNum}
+
+ ))}
+ currentPage < totalPages && setCurrentPage(currentPage + 1)}
+ $disabled={currentPage >= totalPages}
+ >
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
+export default ProductComments;
+
+const StyledNoCommentContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ margin-bottom: 59px;
+
+ img {
+ width: 196px;
+ height: 196px;
+ }
+
+ div {
+ ${applyFlexColumn('16px')}
+ }
+`;
+
+const StyledCommentContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ width: 100%;
+ margin-bottom: 40px;
+`;
+
+const StyledCommentInput = styled.div`
+ ${applyFlexColumn('16px')}
+`;
+
+const StyledCommentTitle = styled.div`
+ ${applyFontStyles(FontTypes.SEMIBOLD16, ColorTypes.SECONDARY_GRAY_800)}
+`;
+
+const StyledButtonWrapper = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+ height: 100%;
+`;
+
+const StyledButton = styled.button`
+ width: 74px;
+ height: 42px;
+ padding: 12px 23px;
+ background-color: ${({ theme }) => theme.colors[ColorTypes.PRIMARY_100]};
+
+ &:disabled {
+ background-color: ${({ theme }) => theme.colors[ColorTypes.SECONDARY_GRAY_400]};
+ }
+`;
+
+const StyledCommentList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: 100%;
+`;
+
+const StyledCommentContent = styled.div`
+ ${applyFontStyles(FontTypes.REGULAR14, ColorTypes.SECONDARY_GRAY_800)}
+`;
+
+const StyledUserInfoWrapper = styled.div`
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+`;
+
+const StyledEditCommentWrapper = styled.div`
+ ${applyFlexColumn('16px')}
+ width: 100%;
+`;
+
+const StyledEditComment = styled.div`
+ ${applyFlexColumn('16px')}
+ width: 100%;
+`;
+
+const StyledCommentWrapper = styled.div`
+ ${applyFlexColumn('24px')}
+ padding-bottom: 12px;
+ border-bottom: 1px solid ${({ theme }) => theme.colors[ColorTypes.SECONDARY_GRAY_200]};
+`;
+
+const StyledCommentContentWrapper = styled.div`
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+`;
+
+const StyledUserInfo = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+const StyledEditUserInfo = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+`;
+
+const StyledProfileContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+const StyledEditButtonContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 24px;
+`;
+
+const StyledProfileWrapper = styled.div`
+ width: 32px;
+ height: 32px;
+`;
+
+const StyledNameWrapper = styled.div`
+ ${applyFlexColumn('4px')}
+`;
+
+const StyledEditButtonWrapper = styled.div`
+ display: flex;
+ gap: 24px;
+`;
+
+const StyledEditCancelButton = styled.button`
+ background-color: transparent;
+ ${applyFontStyles(FontTypes.SEMIBOLD16, ColorTypes.SECONDARY_GRAY_500)}
+`;
+
+const StyledEditSaveButton = styled.button`
+ width: 106px;
+ height: 42px;
+ padding: 12px 23px;
+ ${applyFontStyles(FontTypes.SEMIBOLD16, ColorTypes.SECONDARY_GRAY_100)}
+`;
+
+const StyledName = styled.p`
+ ${applyFontStyles(FontTypes.REGULAR12, ColorTypes.SECONDARY_GRAY_600)}
+`;
+
+const StyledCreatedAt = styled.p`
+ ${applyFontStyles(FontTypes.REGULAR12, ColorTypes.SECONDARY_GRAY_400)}
+`;
+
+const StyledKebabWrapper = styled.div`
+ width: 24px;
+ height: 24px;
+`;
diff --git a/src/components/pages/ItemDetailPage/ProductInfo.jsx b/src/components/pages/ItemDetailPage/ProductInfo.jsx
new file mode 100644
index 00000000..5644c6e6
--- /dev/null
+++ b/src/components/pages/ItemDetailPage/ProductInfo.jsx
@@ -0,0 +1,271 @@
+import { useEffect, useState } from 'react';
+import styled from 'styled-components';
+import { useParams } from 'react-router-dom';
+import { getProductDetail } from '../../../api/api';
+import { ColorTypes, FontTypes } from '../../../styles/theme';
+import { applyFontStyles, applyFlexColumn } from '../../../styles/mixins';
+import { StyledTagList, StyledTagWrapper } from '../../UI/Taginput';
+import profile from '../../../assets/images/icons/ic_profile.png';
+import heart from '../../../assets/images/icons/ic_heart.svg';
+import kebab from '../../../assets/images/icons/ic_kebab.svg';
+
+function ProductInfo() {
+ const [product, setProduct] = useState(null);
+ const [isTags, setIsTags] = useState(false);
+ const { productId } = useParams();
+
+ useEffect(() => {
+ const fetchProductDetail = async () => {
+ const res = await getProductDetail(productId);
+ setProduct(res);
+ setIsTags(res.tags.length > 0);
+ };
+ fetchProductDetail();
+ }, [productId]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {product?.name}
+ {product?.price.toLocaleString()}원
+
+
+
+
+
+ 상품 소개
+ {product?.description}
+
+
+ 상품 태그
+
+ {isTags ? (
+ product.tags.map((tag) => (
+
+ {`#${tag}`}
+
+ ))
+ ) : (
+ 태그가 없습니다.
+ )}
+
+
+
+
+
+
+
+
+
+
+ {product?.ownerNickname}
+
+ {product?.createdAt.split('T')[0].split('-').join('. ')}
+
+
+
+
+
+
+
+ {product?.favoriteCount.toLocaleString()}
+
+
+
+
+
+
+ );
+}
+
+export default ProductInfo;
+
+const StyledContainer = styled.div`
+ padding-bottom: 33px;
+ border-bottom: 1px solid ${({ theme }) => theme.colors[ColorTypes.SECONDARY_GRAY_200]};
+`;
+
+const StyledProductInfo = styled.div`
+ ${applyFlexColumn('16px')}
+
+ @media (min-width: 768px) {
+ flex-direction: row;
+ width: 100%;
+ }
+
+ @media (min-width: 1024px) {
+ gap: 24px;
+ }
+`;
+
+const StyledProductImage = styled.div`
+ width: 100%;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ @media (min-width: 768px) {
+ width: 340px;
+ height: 340px;
+ }
+
+ @media (min-width: 1024px) {
+ width: 486px;
+ height: 486px;
+ }
+`;
+
+const StyledProductInfoContainer = styled.div`
+ ${applyFlexColumn()}
+ flex-grow: 1;
+ gap: 40px;
+`;
+
+const StyledProductInfoWrapper = styled.div`
+ ${applyFlexColumn('16px')}
+`;
+
+const StyledProductNamePrice = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ padding-bottom: 16px;
+ border-bottom: 1px solid ${({ theme }) => theme.colors[ColorTypes.SECONDARY_GRAY_200]};
+
+ img {
+ width: 24px;
+ height: 24px;
+ cursor: pointer;
+ }
+`;
+
+const StyledProductNamePriceWrapper = styled.div`
+ ${applyFlexColumn()}
+`;
+
+const StyledProductName = styled.div`
+ ${applyFontStyles(FontTypes.SEMIBOLD16, ColorTypes.SECONDARY_GRAY_800)};
+
+ @media (min-width: 768px) {
+ ${applyFontStyles(FontTypes.SEMIBOLD20, ColorTypes.SECONDARY_GRAY_800)}
+ }
+
+ @media (min-width: 1024px) {
+ ${applyFontStyles(FontTypes.SEMIBOLD24, ColorTypes.SECONDARY_GRAY_800)}
+ }
+`;
+
+const StyledProductPrice = styled.div`
+ ${applyFontStyles(FontTypes.SEMIBOLD40, ColorTypes.SECONDARY_GRAY_800)};
+`;
+
+const StyledProductDesContainer = styled.div`
+ ${applyFlexColumn('24px')}
+`;
+
+const StyledProductDesWrapper = styled.div`
+ ${applyFlexColumn()}
+`;
+
+const StyledSecTitle = styled.div`
+ ${applyFontStyles(FontTypes.SEMIBOLD14, ColorTypes.SECONDARY_GRAY_800)};
+`;
+
+const StyledProductDescription = styled.div`
+ ${applyFontStyles(FontTypes.REGULAR16, ColorTypes.SECONDARY_GRAY_400)};
+`;
+
+const StyledProductTagWrapper = styled.div`
+ ${applyFlexColumn()}
+`;
+
+const StyledNoTag = styled.span`
+ ${applyFontStyles(FontTypes.REGULAR16, ColorTypes.SECONDARY_GRAY_400)}
+`;
+
+const StyledProductOwnerInfo = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+`;
+
+const StyledOwnerInfo = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+`;
+
+const StyledOwnerProfile = styled.div`
+ width: 40px;
+ height: 40px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+`;
+
+const StyledOwnerNickname = styled.div`
+ ${applyFontStyles(FontTypes.SEMIBOLD14, ColorTypes.SECONDARY_GRAY_600)};
+`;
+
+const StyledProductCreatedAt = styled.div`
+ ${applyFontStyles(FontTypes.REGULAR14, ColorTypes.SECONDARY_GRAY_400)};
+`;
+
+const StyledDeviderWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ height: 34px;
+`;
+
+const StyledDevider = styled.div`
+ width: 1px;
+ height: 100%;
+ background-color: ${({ theme }) => theme.colors[ColorTypes.SECONDARY_GRAY_200]};
+`;
+
+const StyledProductFavorite = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ width: fit-content;
+ padding: 4px 12px;
+ border-radius: 35px;
+ border: 1px solid ${({ theme }) => theme.colors[ColorTypes.SECONDARY_GRAY_100]};
+ margin-left: 14px;
+
+ img {
+ width: 20px;
+ height: 17.5px;
+ }
+`;
+
+const StyledProductFavoriteCount = styled.div`
+ ${applyFontStyles(FontTypes.MEDIUM16, ColorTypes.SECONDARY_GRAY_500)};
+`;
diff --git a/src/components/pages/LoginPage.jsx b/src/components/pages/LoginPage.jsx
index 3abbd607..69c0e3f9 100644
--- a/src/components/pages/LoginPage.jsx
+++ b/src/components/pages/LoginPage.jsx
@@ -1,9 +1,227 @@
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import styled from 'styled-components';
+
+import { applyFontStyles } from '../../styles/mixins';
+import { ColorTypes, FontTypes } from '../../styles/theme';
+import InputField from '../UI/InputField';
+import useFormValidation from '../../hooks/useFormValidation';
+import { postLogin } from '../../api/api';
+import { useAuth } from '../../context/AuthContext';
+
+import logo from '../../assets/images/logo/logo.svg';
+import google from '../../assets/images/icons/ic_google.png';
+import kakao from '../../assets/images/icons/ic_kakao.png';
+
function LoginPage() {
+ const navigate = useNavigate();
+ const { login } = useAuth();
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const { errors, setErrors, validate, validateField } = useFormValidation('login');
+
+ const handleLogin = async (e) => {
+ e.preventDefault();
+ const validationResult = validate({ email, password });
+ setErrors(validationResult);
+ if (Object.keys(validationResult).length > 0) return;
+
+ try {
+ const res = await postLogin(email, password);
+ localStorage.setItem('token', res.accessToken);
+ login(res.accessToken);
+ navigate('/');
+ } catch (error) {
+ console.error('로그인 실패:', error);
+ }
+ };
+
+ const handleEmailChange = (e) => {
+ const value = e.target.value;
+ setEmail(value);
+ setErrors((prev) => ({
+ ...prev,
+ email: validateField('email', value, { email: value, password }, 'login'),
+ }));
+ };
+
+ const handlePasswordChange = (e) => {
+ const value = e.target.value;
+ setPassword(value);
+ setErrors((prev) => ({
+ ...prev,
+ password: validateField('password', value, { email, password: value }, 'login'),
+ }));
+ };
+
+ const handleEmailBlur = (e) => {
+ const value = e.target.value;
+ setErrors((prev) => ({
+ ...prev,
+ email: validateField('email', value, { email: value, password }, 'login'),
+ }));
+ };
+
+ const handlePasswordBlur = (e) => {
+ const value = e.target.value;
+ setErrors((prev) => ({
+ ...prev,
+ password: validateField('password', value, { email, password: value }, 'login'),
+ }));
+ };
+
+ const hasError = Object.values(errors).some((v) => !!v);
+ const isFilled = email && password;
+ const disabled = hasError || !isFilled;
+
return (
-
-
LoginPage
-
+
+
+
+
+
+
+ 로그인
+
+
+
+ 간편 로그인하기
+
+
+
+
+
+
+
+
+
+
+ 판다마켓이 처음이신가요?
+ 회원가입
+
+
);
}
export default LoginPage;
+
+const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 33px;
+ margin-top: 50px;
+ max-width: 640px;
+ width: 100%;
+ padding: ${({ theme }) => theme.spacing.mobile};
+
+ @media (min-width: 768px) {
+ padding: ${({ theme }) => theme.spacing.tablet};
+ margin-left: auto;
+ margin-right: auto;
+ }
+`;
+
+const StyledLogo = styled.img`
+ width: 198px;
+ height: 66px;
+
+ @media (min-width: 768px) {
+ width: 396px;
+ height: 132px;
+ }
+`;
+
+const StyledForm = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: 100%;
+`;
+
+const StyledButton = styled.button`
+ padding: 16px 124px;
+ border-radius: 40px;
+ ${applyFontStyles(FontTypes.SEMIBOLD20, ColorTypes.SECONDARY_GRAY_100)}
+
+ &:disabled {
+ background-color: ${({ theme }) => theme.colors[ColorTypes.SECONDARY_GRAY_400]};
+ }
+`;
+
+const StyledSnsLogin = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ max-width: 640px;
+ padding: 16px 24px;
+ background-color: #e6f2ff;
+ border-radius: 8px;
+
+ p {
+ ${applyFontStyles(FontTypes.MEDIUM16, ColorTypes.SECONDARY_GRAY_800)}
+ }
+`;
+
+const StyledSnsLoginIcons = styled.div`
+ display: flex;
+ gap: 16px;
+
+ img {
+ width: 42px;
+ height: 42px;
+ }
+`;
+
+const StyledSignupLink = styled.div`
+ display: flex;
+ gap: 4px;
+
+ p {
+ ${applyFontStyles(FontTypes.MEDIUM14, ColorTypes.SECONDARY_GRAY_800)}
+ }
+
+ a {
+ text-decoration: underline;
+ ${applyFontStyles(FontTypes.MEDIUM14, ColorTypes.PRIMARY_100)}
+ }
+`;
diff --git a/src/components/pages/MarketPage/AddItemButton.jsx b/src/components/pages/MarketPage/AddItemButton.jsx
index 09a62887..52bfd4a7 100644
--- a/src/components/pages/MarketPage/AddItemButton.jsx
+++ b/src/components/pages/MarketPage/AddItemButton.jsx
@@ -5,9 +5,9 @@ function AddItemButton() {
return (
);
@@ -15,7 +15,7 @@ function AddItemButton() {
export default AddItemButton;
-const StButton = styled.button`
+const StyledButton = styled.button`
width: 133px;
height: 42px;
`;
diff --git a/src/components/pages/MarketPage/AllProducts.jsx b/src/components/pages/MarketPage/AllProducts.jsx
index dec05be2..63a51e26 100644
--- a/src/components/pages/MarketPage/AllProducts.jsx
+++ b/src/components/pages/MarketPage/AllProducts.jsx
@@ -75,73 +75,73 @@ function AllProducts() {
}
return (
-
+
{isTablet ? (
<>
-
+
전체 상품
-
+
setOrderBy(value)} />
-
-
+
+
>
) : (
<>
-
-
+
+
전체 상품
-
+
-
+
setOrderBy(value)} />
-
-
+
+
>
)}
-
+
{items?.map((item) => (
))}
-
+
-
- setCurrentPage(currentPage - 1)}>
+
+ setCurrentPage(currentPage - 1)}>
-
+
{pageNumbers.map((pageNum) => (
- setCurrentPage(pageNum)}
$isActive={pageNum === currentPage}
>
{pageNum}
-
+
))}
- setCurrentPage(currentPage + 1)}>
+ setCurrentPage(currentPage + 1)}>
-
-
-
+
+
+
);
}
export default AllProducts;
-const AllProductsContainer = styled.div`
+const StyledAllProductsContainer = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
@@ -149,7 +149,7 @@ const AllProductsContainer = styled.div`
margin-bottom: 40px;
`;
-const HeaderContainer = styled.div`
+const StyledHeaderContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
@@ -161,7 +161,7 @@ const HeaderContainer = styled.div`
}
`;
-const HeaderWrapper = styled.div`
+const StyledHeaderWrapper = styled.div`
display: flex;
justify-content: space-between;
@@ -173,14 +173,14 @@ const HeaderWrapper = styled.div`
}
`;
-const SecondHeaderWrapper = styled.div`
+const StyledSecondHeaderWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
`;
-const ItemCardContainer = styled.div`
+const StyledItemCardContainer = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 32px 8px;
@@ -194,14 +194,14 @@ const ItemCardContainer = styled.div`
}
`;
-const Pagination = styled.div`
+export const StyledPagination = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
`;
-const Circle = styled.div`
+export const StyledCircle = styled.div`
display: flex;
align-items: center;
justify-content: center;
diff --git a/src/components/pages/MarketPage/BestProducts.jsx b/src/components/pages/MarketPage/BestProducts.jsx
index 7cab6feb..d9d2330a 100644
--- a/src/components/pages/MarketPage/BestProducts.jsx
+++ b/src/components/pages/MarketPage/BestProducts.jsx
@@ -31,31 +31,31 @@ function BestProducts() {
}, [pageSize]);
return (
-
+
베스트 상품
-
+
{bestItems?.map((item) => (
))}
-
-
+
+
);
}
export default BestProducts;
-const BestProductsContainer = styled.div`
+const StyledBestProductsContainer = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
`;
-const ItemCardContainer = styled.div`
+const StyledItemCardContainer = styled.div`
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 8px;
diff --git a/src/components/pages/MarketPage/ItemCard.jsx b/src/components/pages/MarketPage/ItemCard.jsx
index 1f14d9db..09988d3d 100644
--- a/src/components/pages/MarketPage/ItemCard.jsx
+++ b/src/components/pages/MarketPage/ItemCard.jsx
@@ -1,4 +1,5 @@
import styled from 'styled-components';
+import { Link } from 'react-router-dom';
import { applyFontStyles } from '../../../styles/mixins';
import { ColorTypes, FontTypes } from '../../../styles/theme';
@@ -6,59 +7,66 @@ import heart from '../../../assets/images/icons/ic_heart.svg';
function ItemCard({ item }) {
return (
-
-
- {item?.name}
- {item?.price.toLocaleString()}원
-
-
-
+
+
- {item?.favoriteCount.toLocaleString()}
-
-
+ {item?.name}
+ {item?.price.toLocaleString()}원
+
+
+
+ {item?.favoriteCount.toLocaleString()}
+
+
+
);
}
export default ItemCard;
-const ItemCardContainer = styled.div`
+const StyledLink = styled(Link)`
+ text-decoration: none;
+ color: inherit;
+`;
+
+const StyledItemCardContainer = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`;
-const ItemImage = styled.img`
+const StyledItemImage = styled.img`
width: 100%;
aspect-ratio: 1/1;
object-fit: cover;
border-radius: 30px;
`;
-const ItemName = styled.div`
+const StyledItemName = styled.div`
${applyFontStyles(FontTypes.MEDIUM14, ColorTypes.SECONDARY_GRAY_800)};
`;
-const ItemPrice = styled.div`
+const StyledItemPrice = styled.div`
${applyFontStyles(FontTypes.BOLD16, ColorTypes.SECONDARY_GRAY_800)};
`;
-const LikeCount = styled.div`
+const StyledLikeCount = styled.div`
display: flex;
align-items: flex-start;
gap: 4px;
`;
-const LikeIcon = styled.img`
+const StyledLikeIcon = styled.img`
width: 14px;
height: 14px;
`;
-const ItemLikes = styled.span`
+const StyledItemLikes = styled.span`
${applyFontStyles(FontTypes.MEDIUM12, ColorTypes.SECONDARY_GRAY_800)};
`;
diff --git a/src/components/pages/MarketPage/MarketPage.jsx b/src/components/pages/MarketPage/MarketPage.jsx
index 5ab61ad5..4a4786f8 100644
--- a/src/components/pages/MarketPage/MarketPage.jsx
+++ b/src/components/pages/MarketPage/MarketPage.jsx
@@ -5,16 +5,16 @@ import AllProducts from './AllProducts';
function MarketPage() {
return (
-
+
-
+
);
}
export default MarketPage;
-const MarketPageContainer = styled.div`
+const StyledMarketPageContainer = styled.div`
padding: 0 ${({ theme }) => theme.spacing.mobile};
@media (min-width: 768px) {
@@ -23,5 +23,8 @@ const MarketPageContainer = styled.div`
@media (min-width: 1200px) {
padding: 0 ${({ theme }) => theme.spacing.desktop};
+ max-width: 1200px;
+ margin-left: auto;
+ margin-right: auto;
}
`;
diff --git a/src/components/pages/SignupPage.jsx b/src/components/pages/SignupPage.jsx
new file mode 100644
index 00000000..d2e0eb99
--- /dev/null
+++ b/src/components/pages/SignupPage.jsx
@@ -0,0 +1,288 @@
+import { Link, useNavigate } from 'react-router-dom';
+import { useState } from 'react';
+import styled from 'styled-components';
+import logo from '../../assets/images/logo/logo.svg';
+import google from '../../assets/images/icons/ic_google.png';
+import kakao from '../../assets/images/icons/ic_kakao.png';
+import InputField from '../UI/InputField';
+import { applyFontStyles } from '../../styles/mixins';
+import { ColorTypes, FontTypes } from '../../styles/theme';
+import useFormValidation from '../../hooks/useFormValidation';
+import { postSignup } from '../../api/api';
+
+function SignupPage() {
+ const navigate = useNavigate();
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [nickname, setNickname] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const { errors, setErrors, validate, validateField } = useFormValidation('signup');
+
+ const handleSignup = async (e) => {
+ e.preventDefault();
+ const validationResult = validate({ email, password, nickname, confirmPassword });
+ setErrors(validationResult);
+ if (Object.keys(validationResult).length > 0) return;
+
+ try {
+ const res = await postSignup(email, password, nickname, confirmPassword);
+ localStorage.setItem('token', res.accessToken);
+ navigate('/');
+ } catch (error) {
+ console.error('로그인 실패:', error);
+ }
+ };
+
+ const handleEmailChange = (e) => {
+ const value = e.target.value;
+ setEmail(value);
+ setErrors((prev) => ({
+ ...prev,
+ email: validateField('email', value, { email: value, password, nickname, confirmPassword }, 'signup'),
+ }));
+ };
+
+ const handleNicknameChange = (e) => {
+ const value = e.target.value;
+ setNickname(value);
+ setErrors((prev) => ({
+ ...prev,
+ nickname: validateField('nickname', value, { email, password, nickname: value, confirmPassword }, 'signup'),
+ }));
+ };
+
+ const handlePasswordChange = (e) => {
+ const value = e.target.value;
+ setPassword(value);
+ setErrors((prev) => ({
+ ...prev,
+ password: validateField('password', value, { email, password: value, nickname, confirmPassword }, 'signup'),
+ }));
+ };
+
+ const handleConfirmPasswordChange = (e) => {
+ const value = e.target.value;
+ setConfirmPassword(value);
+ setErrors((prev) => ({
+ ...prev,
+ confirmPassword: validateField(
+ 'confirmPassword',
+ value,
+ { email, password, nickname, confirmPassword: value },
+ 'signup'
+ ),
+ }));
+ };
+
+ const handleEmailBlur = (e) => {
+ const value = e.target.value;
+ setErrors((prev) => ({
+ ...prev,
+ email: validateField('email', value, { email: value, password, nickname, confirmPassword }, 'signup'),
+ }));
+ };
+
+ const handleNicknameBlur = (e) => {
+ const value = e.target.value;
+ setErrors((prev) => ({
+ ...prev,
+ nickname: validateField('nickname', value, { email, password, nickname: value, confirmPassword }, 'signup'),
+ }));
+ };
+
+ const handlePasswordBlur = (e) => {
+ const value = e.target.value;
+ setErrors((prev) => ({
+ ...prev,
+ password: validateField('password', value, { email, password: value, nickname, confirmPassword }, 'signup'),
+ }));
+ };
+
+ const handleConfirmPasswordBlur = (e) => {
+ const value = e.target.value;
+ setErrors((prev) => ({
+ ...prev,
+ confirmPassword: validateField(
+ 'confirmPassword',
+ value,
+ { email, password, nickname, confirmPassword: value },
+ 'signup'
+ ),
+ }));
+ };
+
+ const hasError = Object.values(errors).some((v) => !!v);
+ const isFilled = email && password && nickname && confirmPassword;
+ const disabled = hasError || !isFilled;
+
+ return (
+
+
+
+
+
+
+
+
+ 회원가입
+
+
+
+ 간편 로그인하기
+
+
+
+
+
+
+
+
+
+
+ 판다마켓이 처음이신가요?
+ 회원가입
+
+
+ );
+}
+
+export default SignupPage;
+
+const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 33px;
+ margin-top: 50px;
+ max-width: 640px;
+ width: 100%;
+ padding: ${({ theme }) => theme.spacing.mobile};
+
+ @media (min-width: 768px) {
+ padding: ${({ theme }) => theme.spacing.tablet};
+ margin-left: auto;
+ margin-right: auto;
+ }
+`;
+
+const StyledLogo = styled.img`
+ width: 198px;
+ height: 66px;
+
+ @media (min-width: 768px) {
+ width: 396px;
+ height: 132px;
+ }
+`;
+
+const StyledForm = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: 100%;
+`;
+
+const StyledButton = styled.button`
+ padding: 16px 124px;
+ border-radius: 40px;
+ ${applyFontStyles(FontTypes.SEMIBOLD20, ColorTypes.SECONDARY_GRAY_100)}
+
+ &:disabled {
+ background-color: ${({ theme }) => theme.colors[ColorTypes.SECONDARY_GRAY_400]};
+ }
+`;
+
+const StyledSnsLogin = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ max-width: 640px;
+ padding: 16px 24px;
+ background-color: #e6f2ff;
+ border-radius: 8px;
+
+ p {
+ ${applyFontStyles(FontTypes.MEDIUM16, ColorTypes.SECONDARY_GRAY_800)}
+ }
+`;
+
+const StyledSnsLoginIcons = styled.div`
+ display: flex;
+ gap: 16px;
+
+ img {
+ width: 42px;
+ height: 42px;
+ }
+`;
+
+const StyledSignupLink = styled.div`
+ display: flex;
+ gap: 4px;
+
+ p {
+ ${applyFontStyles(FontTypes.MEDIUM14, ColorTypes.SECONDARY_GRAY_800)}
+ }
+
+ a {
+ text-decoration: underline;
+ ${applyFontStyles(FontTypes.MEDIUM14, ColorTypes.PRIMARY_100)}
+ }
+`;
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
new file mode 100644
index 00000000..312aed8f
--- /dev/null
+++ b/src/context/AuthContext.jsx
@@ -0,0 +1,41 @@
+import { createContext, useContext, useState, useEffect } from 'react';
+
+const AuthContext = createContext();
+
+export function AuthProvider({ children }) {
+ const [token, setToken] = useState(() => localStorage.getItem('token'));
+ const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem('token'));
+
+ useEffect(() => {
+ setIsAuthenticated(!!token);
+ }, [token]);
+
+ const login = (token) => {
+ localStorage.setItem('token', token);
+ setToken(token);
+ setIsAuthenticated(true);
+ };
+
+ const logout = () => {
+ localStorage.removeItem('token');
+ setToken(null);
+ setIsAuthenticated(false);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ return useContext(AuthContext);
+}
diff --git a/src/hooks/useFormValidation.js b/src/hooks/useFormValidation.js
new file mode 100644
index 00000000..76d896c4
--- /dev/null
+++ b/src/hooks/useFormValidation.js
@@ -0,0 +1,43 @@
+import { useState } from 'react';
+
+const validateField = (name, value, values, mode = 'signup') => {
+ let error = '';
+ if (name === 'email') {
+ if (!value) error = '이메일을 입력해주세요.';
+ else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) error = '이메일 형식이 올바르지 않습니다.';
+ }
+ if (name === 'password') {
+ if (!value) error = '비밀번호를 입력해주세요.';
+ else if (value.length < 8) error = '비밀번호는 8자 이상이어야 합니다.';
+ }
+ if (mode === 'signup') {
+ if (name === 'nickname') {
+ if (!value) error = '닉네임을 입력해주세요.';
+ }
+ if (name === 'confirmPassword') {
+ if (!value) error = '비밀번호 확인을 입력해주세요.';
+ else if (value !== values.password) error = '비밀번호가 일치하지 않습니다.';
+ else if (value.length < 8) error = '비밀번호는 8자 이상이어야 합니다.';
+ }
+ }
+ return error;
+};
+
+const useFormValidation = (mode = 'signup') => {
+ const [errors, setErrors] = useState({});
+
+ // 전체 폼 검사
+ const validate = (values) => {
+ const newErrors = {};
+ Object.keys(values).forEach((key) => {
+ const error = validateField(key, values[key], values, mode);
+ if (error) newErrors[key] = error;
+ });
+ setErrors(newErrors);
+ return newErrors;
+ };
+
+ return { errors, setErrors, validate, validateField };
+};
+
+export default useFormValidation;
diff --git a/src/hooks/useFormatTime.js b/src/hooks/useFormatTime.js
new file mode 100644
index 00000000..034dd321
--- /dev/null
+++ b/src/hooks/useFormatTime.js
@@ -0,0 +1,11 @@
+function useFormatTime(time) {
+ const now = new Date();
+ const date = new Date(time);
+ const diffTime = now.getTime() - date.getTime();
+
+ const diffHours = Math.floor(diffTime / (1000 * 60 * 60));
+
+ return `${diffHours}시간 전`;
+}
+
+export default useFormatTime;
diff --git a/src/styles/mixins.js b/src/styles/mixins.js
index 2707d383..7a66ff3a 100644
--- a/src/styles/mixins.js
+++ b/src/styles/mixins.js
@@ -6,3 +6,9 @@ export const applyFontStyles = (fontType, color = 'secGray900') => css`
line-height: ${({ theme }) => theme.fonts[fontType].lineHeight};
color: ${({ theme }) => theme.colors[color]};
`;
+
+export const applyFlexColumn = (gap = '8px') => css`
+ display: flex;
+ flex-direction: column;
+ gap: ${gap};
+`;
diff --git a/src/styles/theme.js b/src/styles/theme.js
index 71c4e84d..f1a7df26 100644
--- a/src/styles/theme.js
+++ b/src/styles/theme.js
@@ -17,6 +17,7 @@ export const ColorTypes = {
};
export const FontTypes = {
+ SEMIBOLD40: 'semibold40',
BOLD32: 'bold32',
SEMIBOLD32: 'semibold32',
BOLD24: 'bold24',
@@ -71,6 +72,10 @@ const theme = {
},
fonts: {
+ semibold40: {
+ fontSize: '2.5rem',
+ fontWeight: 600,
+ },
bold32: {
fontSize: '2rem',
fontWeight: 700,