-
+
+ {src ? (
+
+ ) : (
+ 이미지 없음
+ )}
);
}
diff --git a/react/my-react-app/src/components/Layout.jsx b/react/my-react-app/src/components/Layout.jsx
new file mode 100644
index 00000000..3f1c211a
--- /dev/null
+++ b/react/my-react-app/src/components/Layout.jsx
@@ -0,0 +1,36 @@
+import React from "react";
+import { Outlet, useLocation } from "react-router-dom";
+import styled from "styled-components";
+import Header from "./Header";
+import Footer from "./Footer";
+
+const LayoutContainer = styled.div`
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+`;
+
+const MainContent = styled.main`
+ flex: 1;
+ padding-top: 30px; // 줄인 패딩
+`;
+
+function Layout() {
+ const location = useLocation();
+ // Hide footer on /additem, /items, and product detail pages (/items/:productId)
+ const hideFooter = location.pathname === "/additem" ||
+ location.pathname === "/items" ||
+ location.pathname.startsWith("/items/");
+
+ return (
+
+
+
+
+
+ {!hideFooter && }
+
+ );
+}
+
+export default Layout;
diff --git a/react/my-react-app/src/components/Pagination.jsx b/react/my-react-app/src/components/Pagination.jsx
index fdec80a4..e2dff370 100644
--- a/react/my-react-app/src/components/Pagination.jsx
+++ b/react/my-react-app/src/components/Pagination.jsx
@@ -16,7 +16,7 @@ const PageButton = styled.button`
justify-content: center;
width: 36px;
height: 36px;
- border-radius: 8px;
+ border-radius: 50%;
border: 1px solid #e5e8ec;
background-color: #ffffff;
color: #4e5968;
diff --git a/react/my-react-app/src/components/SearchForm.jsx b/react/my-react-app/src/components/SearchForm.jsx
index 9dae9d47..53dcc639 100644
--- a/react/my-react-app/src/components/SearchForm.jsx
+++ b/react/my-react-app/src/components/SearchForm.jsx
@@ -3,20 +3,22 @@ import styled from "styled-components";
const SearchFormContainer = styled.form`
width: 100%; /* Use full width on mobile */
- max-width: 400px; /* Allow it to grow larger on mobile */
+ max-width: 300px; /* Reduced max-width */
+ flex-shrink: 1;
@media (min-width: 768px) {
width: 242px;
}
@media (min-width: 1280px) {
- width: 470px;
+ width: 300px;
}
@media (max-width: 767px) {
flex-grow: 1; /* Take up available space */
flex-shrink: 1;
- width: calc(100% - 54px); /* Leave room for the dropdown */
+ width: 100%; /* Full width on mobile */
+ margin-top: 10px;
}
`;
diff --git a/react/my-react-app/src/components/comment/CommentItem.jsx b/react/my-react-app/src/components/comment/CommentItem.jsx
new file mode 100644
index 00000000..cdf27197
--- /dev/null
+++ b/react/my-react-app/src/components/comment/CommentItem.jsx
@@ -0,0 +1,125 @@
+import React from "react";
+import styled from "styled-components";
+
+const CommentItemContainer = styled.div`
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #f0f0f0;
+`;
+
+const CommentHeader = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 8px;
+`;
+
+const CommentText = styled.p`
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.5;
+ color: #333;
+ flex-grow: 1;
+`;
+
+const CommentFooter = styled.div`
+ display: flex;
+ align-items: center;
+ margin-top: 8px;
+ font-size: 12px;
+ color: #999;
+`;
+
+const CommentAvatar = styled.div`
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background-color: #f0f0f0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #666;
+ font-weight: 600;
+ flex-shrink: 0;
+ margin-right: 8px;
+`;
+
+const CommentAuthor = styled.span`
+ font-weight: 500;
+ margin-right: 8px;
+`;
+
+const CommentDate = styled.span`
+ color: #999;
+`;
+
+const CommentKebabMenu = styled.div`
+ position: relative;
+ cursor: pointer;
+`;
+
+const KebabIcon = styled.div`
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #666;
+
+ &:hover {
+ color: #333;
+ }
+`;
+
+const KebabMenuDropdown = styled.div`
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background-color: white;
+ border: 1px solid #e5e8ec;
+ border-radius: 4px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ z-index: 10;
+ min-width: 100px;
+`;
+
+const KebabMenuItem = styled.button`
+ width: 100%;
+ text-align: left;
+ padding: 8px 12px;
+ background: none;
+ border: none;
+ font-size: 14px;
+ color: #333;
+ cursor: pointer;
+
+ &:hover {
+ background-color: #f5f5f5;
+ }
+`;
+
+function CommentItem({ comment, onToggleMenu }) {
+ return (
+
+
+ {comment.content}
+
+ onToggleMenu(comment.id)}>⋮
+ {comment.showMenu && (
+
+ 수정하기
+ 삭제하기
+
+ )}
+
+
+
+ {comment.avatar}
+ {comment.author}
+ {comment.timeAgo}
+
+
+ );
+}
+
+export default CommentItem;
diff --git a/react/my-react-app/src/components/comment/CommentSection.jsx b/react/my-react-app/src/components/comment/CommentSection.jsx
new file mode 100644
index 00000000..d35011da
--- /dev/null
+++ b/react/my-react-app/src/components/comment/CommentSection.jsx
@@ -0,0 +1,133 @@
+import React from "react";
+import styled from "styled-components";
+import CommentItem from "./CommentItem";
+
+const CommentsContainer = styled.div`
+ margin-top: 40px;
+ width: 100%;
+`;
+
+const SectionTitle = styled.h3`
+ font-size: 18px;
+ font-weight: 600;
+ color: #222;
+ margin-bottom: 16px;
+`;
+
+const CommentForm = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-bottom: 24px;
+ padding: 0;
+ border-radius: 8px;
+`;
+
+const CommentInput = styled.textarea`
+ width: 100%;
+ height: 104px;
+ border-radius: 8px;
+ border: 1px solid #e5e8ec;
+ background-color: #f4f6fa;
+ padding: 16px;
+ font-size: 14px;
+ resize: none;
+ box-sizing: border-box;
+ color: #333333;
+ font-family: inherit;
+
+ &:focus {
+ border-color: #007aff;
+ outline: none;
+ }
+
+ &::placeholder {
+ color: #666;
+ opacity: 1;
+ }
+`;
+
+const SubmitButton = styled.button`
+ align-self: flex-end;
+ padding: 8px 16px;
+ background-color: #6c757d;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s;
+
+ &:hover {
+ background-color: #5a6268;
+ }
+`;
+
+const CommentList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`;
+
+const MessageParagraph = styled.p`
+ text-align: center;
+ color: #8b95a1;
+ padding: 20px;
+ font-size: 14px;
+`;
+
+// 디폴트 문구 정의
+const DEFAULT_COMMENT =
+ "개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다.";
+
+function CommentSection({
+ comments,
+ newComment,
+ loadingComments,
+ commentError,
+ onCommentChange,
+ onCommentSubmit,
+ onCommentFocus,
+ onCommentBlur,
+ onToggleMenu
+}) {
+ return (
+
+ 문의하기
+
+
+ 등록
+
+
+
+ {loadingComments ? (
+ 댓글을 불러오는 중...
+ ) : commentError ? (
+
+ 댓글을 불러오는데 오류가 발생했습니다.
+
+ ) : comments.length === 0 ? (
+
+ 아직 댓글이 없습니다. 처음으로 댓글을 남겨보세요!
+
+ ) : (
+ comments.map((comment) => (
+
+ ))
+ )}
+
+
+ );
+}
+
+export default CommentSection;
diff --git a/react/my-react-app/src/components/product/ProductDetails.jsx b/react/my-react-app/src/components/product/ProductDetails.jsx
new file mode 100644
index 00000000..fffc036f
--- /dev/null
+++ b/react/my-react-app/src/components/product/ProductDetails.jsx
@@ -0,0 +1,34 @@
+import React from "react";
+import styled from "styled-components";
+import ProductInfo from "./ProductInfo";
+import ProductTags from "./ProductTags";
+import SellerInfo from "./SellerInfo";
+
+const ProductDetailsContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+`;
+
+function ProductDetails({ product, onFavoriteClick }) {
+ return (
+
+
+
+
+
+ );
+}
+
+export default ProductDetails;
diff --git a/react/my-react-app/src/components/product/ProductImages.jsx b/react/my-react-app/src/components/product/ProductImages.jsx
new file mode 100644
index 00000000..6418e166
--- /dev/null
+++ b/react/my-react-app/src/components/product/ProductImages.jsx
@@ -0,0 +1,85 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+const ImageContainer = styled.div`
+ width: 343px;
+ height: 343px;
+ margin: 0 auto;
+
+ /* 태블릿 화면 */
+ @media (min-width: 768px) and (max-width: 1023px) {
+ width: 340px;
+ height: 340px;
+ }
+
+ /* 데스크톱 화면 */
+ @media (min-width: 1024px) {
+ width: 486px;
+ height: 486px;
+ }
+`;
+
+const MainImage = styled.img`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 8px;
+`;
+
+const ThumbnailContainer = styled.div`
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+ overflow-x: auto;
+`;
+
+const Thumbnail = styled.img`
+ width: 60px;
+ height: 60px;
+ object-fit: cover;
+ border-radius: 4px;
+ cursor: pointer;
+ opacity: ${(props) => (props.$active ? 1 : 0.6)};
+ border: ${(props) => (props.$active ? "2px solid #4a80f0" : "none")};
+ transition: all 0.2s;
+
+ &:hover {
+ opacity: 1;
+ }
+`;
+
+function ProductImages({ images }) {
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ // 이미지가 없을 경우 기본 이미지 사용
+ const imageList = images && images.length > 0
+ ? images
+ : ['/placeholder-image.jpg'];
+
+ return (
+
+
+
+
+
+ {imageList.length > 1 && (
+
+ {imageList.map((image, index) => (
+ setActiveIndex(index)}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+export default ProductImages;
diff --git a/react/my-react-app/src/components/product/ProductInfo.jsx b/react/my-react-app/src/components/product/ProductInfo.jsx
new file mode 100644
index 00000000..dbf8655e
--- /dev/null
+++ b/react/my-react-app/src/components/product/ProductInfo.jsx
@@ -0,0 +1,170 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import { formatPrice, formatDate } from "../../utils/format";
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`;
+
+const Divider = styled.hr`
+ border: none;
+ border-top: 1px solid #DFDFDF;
+ width: 100%;
+ margin: 16px 0;
+`;
+
+const SectionTitle = styled.h3`
+ font-size: 16px;
+ font-weight: 600;
+ color: #222;
+ margin: 0;
+ margin-bottom: 8px;
+`;
+
+const TitleContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+`;
+
+const Title = styled.h2`
+ font-size: 24px;
+ font-weight: 600;
+ color: #222;
+ margin: 0;
+`;
+
+const Price = styled.div`
+ font-size: 32px;
+ font-weight: 700;
+ color: #222;
+`;
+
+const Description = styled.p`
+ font-size: 16px;
+ line-height: 1.6;
+ color: #4e5968;
+ margin: 0;
+ white-space: pre-wrap;
+`;
+
+const MetaInfo = styled.div`
+ display: flex;
+ align-items: center;
+ margin-top: 8px;
+`;
+
+const CreatedAt = styled.span`
+ color: #8b95a1;
+ font-size: 14px;
+ margin-left: auto; /* 오른쪽 정렬 */
+`;
+
+const FavoriteButton = styled.button`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 16px;
+ border: none;
+ background: ${(props) => (props.$isFavorite ? "#FFE8EC" : "#F4F6FA")};
+ color: ${(props) => (props.$isFavorite ? "#FF597B" : "#4e5968")};
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: all 0.2s;
+
+ &:hover {
+ background: ${(props) => (props.$isFavorite ? "#FFD5DE" : "#E8EBF2")};
+ }
+`;
+
+function ProductInfo({
+ name,
+ price,
+ description,
+ favoriteCount,
+ isFavorite,
+ createdAt,
+ onFavoriteClick,
+}) {
+ const [menuOpen, setMenuOpen] = useState(false);
+ // 케밥 메뉴 아이콘 컴포넌트
+ const KebabMenuIcon = () => (
+
+ );
+
+ const KebabMenuButton = styled.button`
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: #4e5968;
+ padding: 8px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+
+ &:hover {
+ background: #f4f6fa;
+ }
+ `;
+
+ const MenuDropdown = styled.div`
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ width: 120px;
+ z-index: 10;
+ overflow: hidden;
+ `;
+
+ const MenuItem = styled.button`
+ width: 100%;
+ text-align: left;
+ padding: 12px 16px;
+ background: none;
+ border: none;
+ font-size: 14px;
+ color: #333;
+ cursor: pointer;
+ transition: background-color 0.2s;
+
+ &:hover {
+ background-color: #f5f5f5;
+ }
+ `;
+
+ return (
+
+
+ {name}
+ setMenuOpen(!menuOpen)}>
+
+ {menuOpen && (
+
+
+
+
+ )}
+
+
+ {formatPrice(price)}원
+
+ 상품 소개
+ {description}
+
+ );
+}
+
+export default ProductInfo;
diff --git a/react/my-react-app/src/components/product/ProductTags.jsx b/react/my-react-app/src/components/product/ProductTags.jsx
new file mode 100644
index 00000000..a79840c2
--- /dev/null
+++ b/react/my-react-app/src/components/product/ProductTags.jsx
@@ -0,0 +1,50 @@
+import React from "react";
+import styled from "styled-components";
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
+
+const SectionTitle = styled.h3`
+ font-size: 16px;
+ font-weight: 600;
+ color: #222;
+ margin: 0;
+ margin-bottom: 8px;
+`;
+
+const TagsContainer = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 8px 0;
+`;
+
+const Tag = styled.span`
+ display: inline-block;
+ padding: 6px 12px;
+ background: #f4f6fa;
+ color: #4e5968;
+ border-radius: 16px;
+ font-size: 14px;
+ font-weight: 500;
+`;
+
+function ProductTags({ tags }) {
+ if (!tags || tags.length === 0) return null;
+
+ return (
+
+ 상품 태그
+
+ {tags.map((tag, index) => (
+ #{tag}
+ ))}
+
+
+ );
+}
+
+export default ProductTags;
diff --git a/react/my-react-app/src/components/product/SellerInfo.jsx b/react/my-react-app/src/components/product/SellerInfo.jsx
new file mode 100644
index 00000000..9d9ae5bd
--- /dev/null
+++ b/react/my-react-app/src/components/product/SellerInfo.jsx
@@ -0,0 +1,117 @@
+import React from "react";
+import styled from "styled-components";
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
+
+const SectionTitle = styled.h3`
+ font-size: 16px;
+ font-weight: 600;
+ color: #222;
+ margin: 0;
+ margin-bottom: 8px;
+`;
+
+
+
+const SellerProfile = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ justify-content: space-between;
+`;
+
+const ProfileImage = styled.div`
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: ${(props) =>
+ props.$image ? `url(${props.$image}) center/cover` : "#F4F6FA"};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #8b95a1;
+ font-size: 20px;
+ border: 1px solid #e8ebf2;
+`;
+
+const SellerInfoContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 4px;
+`;
+
+const SellerName = styled.div`
+ font-size: 16px;
+ font-weight: 500;
+ color: #222;
+`;
+
+const SellerDate = styled.div`
+ font-size: 14px;
+ color: #8b95a1;
+`;
+
+const FavoriteButton = styled.button`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 16px;
+ border: 1px solid #e5e8ec;
+ background: transparent;
+ color: ${(props) => (props.$isFavorite ? "#FF597B" : "#4e5968")};
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: all 0.2s;
+
+ &:hover {
+ background: #f9f9f9;
+ }
+`;
+
+const FavoriteIcon = styled.img`
+ width: 15px;
+ height: 15px;
+ margin-right: 4px;
+ object-fit: contain;
+ vertical-align: middle;
+`;
+
+
+
+function SellerInfo({ nickname, image, createdAt, favoriteCount, isFavorite, onFavoriteClick }) {
+ // 프로필 이미지가 없을 경우 이니셜을 표시
+ const initial = nickname ? nickname[0].toUpperCase() : "?";
+
+ // 날짜 포맷팅 함수
+ const formatDate = (dateString) => {
+ const date = new Date(dateString);
+ return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
+ };
+
+ return (
+
+ 판매자 정보
+
+
+
{!image && initial}
+
+ {nickname}
+ {createdAt ? formatDate(createdAt) : '2024.01.02'}
+
+
+
+
+ {favoriteCount || 123}
+
+
+
+ );
+}
+
+export default SellerInfo;
diff --git a/react/my-react-app/src/components/ui/FormField.jsx b/react/my-react-app/src/components/ui/FormField.jsx
index fc4cbe44..2c18dff2 100644
--- a/react/my-react-app/src/components/ui/FormField.jsx
+++ b/react/my-react-app/src/components/ui/FormField.jsx
@@ -4,6 +4,23 @@ import Input from "./Input";
import TextArea from "./TextArea";
import TagInput from "../TagInput";
+const FormFieldWrapper = styled.div`
+ margin-bottom: 16px;
+ width: 100%;
+`;
+
+const Label = styled.label`
+ display: block;
+ font-weight: 600;
+ margin-bottom: 8px;
+`;
+
+const ErrorText = styled.div`
+ color: #f74747;
+ font-size: 14px;
+ margin-top: 8px;
+`;
+
// styled-components 정의
const FormFieldContainer = styled.div`
width: 100%;
@@ -22,12 +39,6 @@ const FormFieldContainer = styled.div`
}
`;
-const FormFieldLabel = styled.label`
- font-weight: 600;
- margin-bottom: 8px;
- display: block;
-`;
-
const StyledInput = styled(Input)`
width: 100%;
height: 56px !important; /* 고정 높이 */
@@ -62,12 +73,6 @@ const TagInputContainer = styled.div`
}
`;
-const ErrorMessage = styled.div`
- color: #f74747;
- font-size: 14px;
- margin-top: 8px;
-`;
-
/**
* 공통 폼 필드 컴포넌트
* @param {string} type - 필드 타입 ('text', 'textarea', 'number', 'tag', 'custom')
@@ -131,11 +136,11 @@ function FormField({
};
return (
-
- {label && {label}}
+
+ {label && }
{renderFieldContent()}
- {error && {error}}
-
+ {error && {error}}
+
);
}
diff --git a/react/my-react-app/src/components/ui/LoadingErrorHandler.jsx b/react/my-react-app/src/components/ui/LoadingErrorHandler.jsx
new file mode 100644
index 00000000..a7666723
--- /dev/null
+++ b/react/my-react-app/src/components/ui/LoadingErrorHandler.jsx
@@ -0,0 +1,24 @@
+import React from "react";
+import styled from "styled-components";
+
+const LoadingSpinner = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+`;
+
+const ErrorMessage = styled.div`
+ text-align: center;
+ color: #e53935;
+ padding: 20px;
+ margin-top: 100px;
+`;
+
+function LoadingErrorHandler({ loading, error, children }) {
+ if (loading) return Loading...;
+ if (error) return {error};
+ return children;
+}
+
+export default LoadingErrorHandler;
diff --git a/react/my-react-app/src/components/ui/NumberInput.jsx b/react/my-react-app/src/components/ui/NumberInput.jsx
new file mode 100644
index 00000000..1d9f6bbe
--- /dev/null
+++ b/react/my-react-app/src/components/ui/NumberInput.jsx
@@ -0,0 +1,52 @@
+import React from "react";
+import styled from "styled-components";
+import FormField from "./FormField";
+
+const Input = styled.input`
+ width: 100%;
+ height: 56px;
+ border-radius: 8px;
+ border: 1px solid #e5e8ec;
+ background-color: #f4f6fa;
+ padding: 0 16px;
+ font-size: 16px;
+ box-sizing: border-box;
+
+ &:focus {
+ border-color: #007aff;
+ }
+
+ &::placeholder {
+ color: #999;
+ }
+
+ /* Remove spinner buttons */
+ &::-webkit-inner-spin-button,
+ &::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+ &[type="number"] {
+ -moz-appearance: textfield;
+ }
+`;
+
+function NumberInput({ label, error, onChange, onKeyDown, ...props }) {
+ const handleChange = (e) => {
+ const value = e.target.value.replace(/[^0-9]/g, "");
+ onChange?.({ ...e, target: { ...e.target, value } });
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default NumberInput;
diff --git a/react/my-react-app/src/components/ui/TextArea.jsx b/react/my-react-app/src/components/ui/TextArea.jsx
index 42413557..fb182606 100644
--- a/react/my-react-app/src/components/ui/TextArea.jsx
+++ b/react/my-react-app/src/components/ui/TextArea.jsx
@@ -1,41 +1,33 @@
import React from "react";
import styled from "styled-components";
+import FormField from "./FormField";
const StyledTextArea = styled.textarea`
width: 100%;
- min-height: 120px;
+ height: 282px;
border-radius: 8px;
- border: none;
- background: #f4f6fa;
- padding: 12px 16px;
+ border: 1px solid #e5e8ec;
+ background-color: #f4f6fa;
+ padding: 16px;
font-size: 16px;
- resize: vertical;
- ${(props) =>
- props.style &&
- Object.entries(props.style)
- .map(([key, value]) => `${key}: ${value};`)
- .join(" ")}
+ resize: none;
+ box-sizing: border-box;
+
+ &:focus {
+ border-color: #007aff;
+ }
+
+ &::placeholder {
+ color: #666;
+ opacity: 1;
+ }
`;
-function TextArea({
- placeholder,
- value,
- onChange,
- maxLength,
- style,
- rows = 4,
- className,
-}) {
+function TextArea({ label, error, ...props }) {
return (
-
+
+
+
);
}
diff --git a/react/my-react-app/src/components/ui/TextInput.jsx b/react/my-react-app/src/components/ui/TextInput.jsx
new file mode 100644
index 00000000..d4284975
--- /dev/null
+++ b/react/my-react-app/src/components/ui/TextInput.jsx
@@ -0,0 +1,32 @@
+import React from "react";
+import styled from "styled-components";
+import FormField from "./FormField";
+
+const Input = styled.input`
+ width: 100%;
+ height: 56px;
+ border-radius: 8px;
+ border: 1px solid #e5e8ec;
+ background-color: #f4f6fa;
+ padding: 0 16px;
+ font-size: 16px;
+ box-sizing: border-box;
+
+ &:focus {
+ border-color: #007aff;
+ }
+
+ &::placeholder {
+ color: #999;
+ }
+`;
+
+function TextInput({ label, error, ...props }) {
+ return (
+
+
+
+ );
+}
+
+export default TextInput;
diff --git a/react/my-react-app/src/global.css b/react/my-react-app/src/global.css
index 9d387c30..a9b01d9a 100644
--- a/react/my-react-app/src/global.css
+++ b/react/my-react-app/src/global.css
@@ -212,10 +212,24 @@ button {
font-weight: 500;
}
+.header-nav a.community-link,
+.header-nav a.market-link {
+ font-weight: bold;
+}
+
.header-nav a:hover {
color: var(--blue);
}
+.header-nav a.active-link {
+ color: var(--gray-600);
+ font-weight: bold;
+}
+
+.header-nav a.active-link:hover {
+ color: var(--blue);
+}
+
.header-right {
display: flex;
align-items: center;
diff --git a/react/my-react-app/src/pages/AddItemPage.jsx b/react/my-react-app/src/pages/AddItemPage.jsx
index 05ab6dff..9ecff43d 100644
--- a/react/my-react-app/src/pages/AddItemPage.jsx
+++ b/react/my-react-app/src/pages/AddItemPage.jsx
@@ -1,177 +1,193 @@
-import React, { useState, useMemo } from "react";
+import React, { useState } from "react";
import styled from "styled-components";
-import Header from "../components/Header";
import ImageUpload from "../components/ImageUpload";
-import Button from "../components/ui/Button";
-import FormField from "../components/ui/FormField";
-
-const PageContainer = styled.div`
- margin: 0 auto;
- padding-top: 120px;
- margin-bottom: 10px;
-`;
-
-// FormFieldContainer 스타일과 동일한 스타일 적용
-const CommonContainer = styled.div`
- width: 100%;
- max-width: 344px; /* 모바일 기본 너비 */
- margin: 0 auto;
- box-sizing: border-box;
-
- /* 태블릿 화면 */
- @media (min-width: 768px) {
- max-width: 696px;
- }
-
- /* 데스크톱 화면 */
- @media (min-width: 1280px) {
- max-width: 1200px;
- }
-`;
-
-const FormHeader = styled(CommonContainer)`
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 24px;
-`;
-
-const Title = styled.h2`
- font-size: 20px;
- font-weight: 700;
- margin: 0;
-`;
-
-const SubmitButton = styled(Button)`
+import TextInput from "../components/ui/TextInput";
+import TextArea from "../components/ui/TextArea";
+import NumberInput from "../components/ui/NumberInput";
+import TagInput from "../components/TagInput";
+import {
+ PageContainer,
+ FormHeader,
+ Title,
+ FormSection,
+ ImageSection,
+ ImageLabel,
+ ErrorMessage,
+} from "../styles/pages/AddItemPage.styled";
+
+// 직접 버튼 컴포넌트 생성
+const RegisterButton = styled.button`
width: 100px;
margin-left: 16px;
-`;
-
-const ImageSection = styled(CommonContainer)`
- margin-bottom: 30px;
-`;
-
-const ImageLabel = styled.div`
+ padding: 8px 16px;
+ font-size: 16px;
font-weight: 600;
- margin-bottom: 8px;
- display: block;
-`;
-
-const ErrorMessage = styled.div`
- color: #f74747;
- font-size: 14px;
- margin-top: 8px;
-`;
-
-const FormSection = styled.div`
- margin-bottom: 24px;
+ height: 40px;
+ border-radius: 8px;
+ border: none;
+ color: white;
+ cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+ background-color: ${(props) => (props.disabled ? "#B0B8C1" : "#3692FF")};
+ opacity: ${(props) => (props.disabled ? 0.7 : 1)};
`;
function AddItemPage() {
- const [images, setImages] = useState([]); // 여러 이미지
- const [title, setTitle] = useState("");
- const [desc, setDesc] = useState("");
- const [price, setPrice] = useState("");
- const [tags, setTags] = useState([]);
+ const [formData, setFormData] = useState({
+ images: [],
+ title: "",
+ desc: "",
+ price: "",
+ tags: [],
+ });
const [error, setError] = useState("");
+ const [priceError, setPriceError] = useState("");
+
+ // 폼 유효성 검사 함수
+ const validateForm = () => {
+ return (
+ formData.images.length > 0 &&
+ formData.title.trim() !== "" &&
+ formData.desc.trim() !== "" &&
+ formData.price.trim() !== "" &&
+ formData.tags.length > 0
+ );
+ };
- // 모든 필수 입력값이 채워졌는지
- const isFormValid = useMemo(() => {
- return title.trim() && desc.trim() && price.trim() && tags.length > 0;
- }, [title, desc, price, tags]);
-
- // 이미지 업로드
+ // 이미지 업로드 핸들러
const handleImagesChange = (imgs) => {
if (imgs.length > 1) {
setError("이미지 등록은 최대 1개까지 가능합니다.");
return;
}
- setImages(imgs);
+ setFormData({ ...formData, images: imgs });
setError("");
};
- // 태그 추가/삭제
+ // 텍스트 입력 핸들러
+ const handleTextChange = (e, field) => {
+ let value = e.target.value;
+
+ if (field === "price") {
+ // 숫자가 아닌 문자가 입력되었는지 확인
+ if (/[^0-9]/.test(value)) {
+ setPriceError("숫자만 입력해주세요.");
+ // 숫자가 아닌 문자 제거
+ value = value.replace(/[^0-9]/g, "");
+ } else {
+ setPriceError("");
+ }
+ }
+
+ setFormData({ ...formData, [field]: value });
+ };
+
+ // 판매가격 입력창 키 입력 이벤트 핸들러
+ const handlePriceKeyDown = (e) => {
+ // 숫자 키, 백스페이스, 딜리트, 탭, 방향키만 허용
+ const allowedKeys = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
+
+ if (!allowedKeys.includes(e.key)) {
+ e.preventDefault();
+ setPriceError("숫자만 입력해주세요.");
+ } else {
+ // 숫자나 허용된 키를 입력하면 에러 메시지 삭제
+ setPriceError("");
+ }
+ };
+
+ // 태그 추가 핸들러
const handleAddTag = (tag) => {
- setTags([...tags, tag]);
+ setFormData({
+ ...formData,
+ tags: [...formData.tags, tag],
+ });
};
+
+ // 태그 제거 핸들러
const handleRemoveTag = (tag) => {
- setTags(tags.filter((t) => t !== tag));
+ setFormData({
+ ...formData,
+ tags: formData.tags.filter((t) => t !== tag),
+ });
};
- // 등록 버튼 클릭
+ // 폼 제출 핸들러
const handleSubmit = (e) => {
e.preventDefault();
- // API 연동 없이 동작만 구현
- alert("상품이 등록되었습니다! (API 연동 전)");
+ if (validateForm()) {
+ alert("상품이 등록되었습니다! (API 연동 전)");
+ console.log("Form submitted with data:", formData);
+ } else {
+ alert("모든 필드를 입력해주세요.");
+ }
};
+ // 폼 유효성 상태
+ const isFormValid = validateForm();
+
return (
- <>
-
-
-
-
- >
+
+
+
);
}
diff --git a/react/my-react-app/src/pages/ProductDetailPage.jsx b/react/my-react-app/src/pages/ProductDetailPage.jsx
new file mode 100644
index 00000000..26cbb39a
--- /dev/null
+++ b/react/my-react-app/src/pages/ProductDetailPage.jsx
@@ -0,0 +1,199 @@
+import React, { useState, useEffect, useCallback } from "react";
+import { useParams } from "react-router-dom";
+import { productAPI } from "../api/products";
+import ProductImages from "../components/product/ProductImages";
+import ProductDetails from "../components/product/ProductDetails";
+import CommentSection from "../components/comment/CommentSection";
+import LoadingErrorHandler from "../components/ui/LoadingErrorHandler";
+import { formatTimeAgo } from "../utils/timeFormat";
+import {
+ PageContainer,
+ ProductDetailContainer,
+ ContentLayout,
+ Divider
+} from "../styles/pages/ProductDetailPage.styled.js";
+
+
+
+function ProductDetailPage() {
+ const { productId } = useParams();
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [product, setProduct] = useState(null);
+ const [comments, setComments] = useState([]);
+ const [loadingComments, setLoadingComments] = useState(false);
+ const [commentError, setCommentError] = useState(null);
+ const [newComment, setNewComment] = useState("");
+ const [submittingComment, setSubmittingComment] = useState(false);
+
+ // 댓글 메뉴 토글 핸들러
+ const handleToggleCommentMenu = (commentId) => {
+ const updatedComments = comments.map((comment) =>
+ comment.id === commentId
+ ? { ...comment, showMenu: !comment.showMenu }
+ : { ...comment, showMenu: false }
+ );
+ setComments(updatedComments);
+ };
+
+ const handleFavoriteClick = async () => {
+ try {
+ if (!product) return;
+
+ const api = product.isFavorite
+ ? productAPI.removeFavorite
+ : productAPI.addFavorite;
+ const { data } = await api(productId);
+ setProduct(data);
+ } catch (err) {
+ console.error("Failed to toggle favorite:", err);
+ }
+ };
+
+ // 댓글 목록 가져오기
+ const fetchComments = useCallback(async () => {
+ if (!productId) return;
+
+ setLoadingComments(true);
+ setCommentError(null);
+
+ try {
+ const { data } = await productAPI.getComments(productId, null, 10);
+ const formattedComments = data.list.map((comment) => ({
+ id: comment.id,
+ author: comment.writer.nickname,
+ content: comment.content,
+ createdAt: comment.createdAt,
+ timeAgo: formatTimeAgo(comment.createdAt),
+ date: new Date(comment.createdAt)
+ .toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ })
+ .replace(/\. /g, "-")
+ .replace(/\.$/, ""),
+ avatar: comment.writer.nickname.charAt(0),
+ showMenu: false,
+ }));
+
+ setComments(formattedComments);
+ } catch (err) {
+ console.error("Failed to fetch comments:", err);
+ setCommentError(
+ err.response?.data?.message || "댓글을 불러오는데 실패했습니다."
+ );
+ } finally {
+ setLoadingComments(false);
+ }
+ }, [productId]);
+
+ // 댓글 작성 제출
+ const handleCommentSubmit = async (e) => {
+ e.preventDefault();
+ if (!newComment.trim() || submittingComment) return;
+
+ setSubmittingComment(true);
+
+ try {
+ const { data } = await productAPI.addComment(productId, {
+ content: newComment,
+ });
+
+ // 새 댓글 추가
+ const newCommentObj = {
+ id: data.id,
+ author: data.writer.nickname,
+ content: data.content,
+ createdAt: data.createdAt,
+ timeAgo: formatTimeAgo(data.createdAt),
+ date: new Date(data.createdAt)
+ .toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ })
+ .replace(/\. /g, "-")
+ .replace(/\.$/, ""),
+ avatar: data.writer.nickname.charAt(0),
+ showMenu: false,
+ };
+
+ setComments([newCommentObj, ...comments]);
+ setNewComment("");
+ } catch (err) {
+ console.error("Failed to add comment:", err);
+ alert(
+ err.response?.data?.message || "댓글 작성에 실패했습니다. 다시 시도해주세요."
+ );
+ } finally {
+ setSubmittingComment(false);
+ }
+ };
+
+ const handleCommentChange = (e) => {
+ setNewComment(e.target.value);
+ };
+
+ // 상품 정보 가져오기
+ useEffect(() => {
+ const fetchProduct = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const { data } = await productAPI.getDetail(productId);
+ setProduct(data);
+ } catch (err) {
+ setError(
+ err.response?.data?.message || "상품 정보를 불러오는데 실패했습니다."
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchProduct();
+ }, [productId]);
+
+ useEffect(() => {
+ if (!loading && product) {
+ fetchComments();
+ }
+ }, [loading, product, fetchComments]);
+
+ return (
+
+ {product && (
+
+
+
+
+
+
+
+
+
+ {}}
+ onCommentBlur={() => {}}
+ onToggleMenu={handleToggleCommentMenu}
+ />
+
+
+ )}
+
+ );
+}
+
+export default ProductDetailPage;
diff --git a/react/my-react-app/src/pages/ItemsPage.jsx b/react/my-react-app/src/pages/ProductsPage.jsx
similarity index 86%
rename from react/my-react-app/src/pages/ItemsPage.jsx
rename to react/my-react-app/src/pages/ProductsPage.jsx
index df013575..b81b6091 100644
--- a/react/my-react-app/src/pages/ItemsPage.jsx
+++ b/react/my-react-app/src/pages/ProductsPage.jsx
@@ -3,11 +3,14 @@ import { useNavigate, useLocation } from "react-router-dom";
import useWindowSize from "../hooks/useWindowSize";
import BestItemsSection from "../components/BestItemsSection";
import AllItemsSection from "../components/AllItemsSection";
-import { ErrorMessage, ItemsPageContainer } from "./ItemsPage.styled";
+import { ErrorMessage, ProductsPageContainer, PageContainer } from "../styles/pages/ProductsPage.styled";
+import Button from "../components/ui/Button";
+
+
const API_BASE_URL = "https://panda-market-api.vercel.app/";
-function ItemsPage() {
+function ProductsPage() {
const navigate = useNavigate();
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
@@ -191,26 +194,28 @@ function ItemsPage() {
}
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
-export default ItemsPage;
+export default ProductsPage;
diff --git a/react/my-react-app/src/pages/SigninPage.jsx b/react/my-react-app/src/pages/SigninPage.jsx
index 5af06adb..f078ba8c 100644
--- a/react/my-react-app/src/pages/SigninPage.jsx
+++ b/react/my-react-app/src/pages/SigninPage.jsx
@@ -1,30 +1,7 @@
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import "../auth.css"; // 경로 수정
-import styled from "styled-components";
-
-const AuthContainer = styled.div`
- width: 100%;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 20px;
- background-color: #fff;
-
- form {
- width: 100%;
- max-width: 640px;
- display: flex;
- flex-direction: column;
- align-items: center;
- }
-
- .input-item {
- width: 100%;
- }
-`;
+import { AuthContainer } from "../styles/pages/SigninPage.styled";
function SigninPage() {
const [email, setEmail] = useState("");
diff --git a/react/my-react-app/src/pages/SignupPage.jsx b/react/my-react-app/src/pages/SignupPage.jsx
index 3dbbf44d..047d84f5 100644
--- a/react/my-react-app/src/pages/SignupPage.jsx
+++ b/react/my-react-app/src/pages/SignupPage.jsx
@@ -1,30 +1,7 @@
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import "../auth.css";
-import styled from "styled-components";
-
-const AuthContainer = styled.div`
- width: 100%;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 20px;
- background-color: #fff;
-
- form {
- width: 100%;
- max-width: 640px;
- display: flex;
- flex-direction: column;
- align-items: center;
- }
-
- .input-item {
- width: 100%;
- }
-`;
+import { AuthContainer } from "../styles/pages/SigninPage.styled";
function SignupPage() {
const [email, setEmail] = useState("");
diff --git a/react/my-react-app/src/styles/common/CommonStyles.js b/react/my-react-app/src/styles/common/CommonStyles.js
new file mode 100644
index 00000000..423723d8
--- /dev/null
+++ b/react/my-react-app/src/styles/common/CommonStyles.js
@@ -0,0 +1,57 @@
+import styled from "styled-components";
+
+// Common container used across multiple pages
+export const PageContainer = styled.div`
+ margin: 0 auto;
+ padding-top: 10px;
+ margin-bottom: 10px;
+`;
+
+// Responsive container with breakpoints for different screen sizes
+export const CommonContainer = styled.div`
+ width: 100%;
+ max-width: 344px; /* 모바일 기본 너비 */
+ margin: 0 auto;
+ box-sizing: border-box;
+
+ /* 태블릿 화면 */
+ @media (min-width: 768px) {
+ max-width: 696px;
+ }
+
+ /* 데스크톱 화면 */
+ @media (min-width: 1280px) {
+ max-width: 1200px;
+ }
+`;
+
+// Common form header used in multiple forms
+export const FormHeader = styled(CommonContainer)`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 24px;
+`;
+
+// Common title style used in multiple components
+export const Title = styled.h2`
+ font-size: 20px;
+ font-weight: 700;
+ margin: 0;
+`;
+
+// Common divider used in multiple pages
+export const Divider = styled.hr`
+ border: none;
+ border-top: 1px solid #dfdfdf;
+ width: 100%;
+ max-width: 1200px;
+ margin: 40px auto;
+`;
+
+// Common error message styling
+export const ErrorMessage = styled.div`
+ color: #f74747;
+ font-size: 14px;
+ margin-top: 8px;
+`;
diff --git a/react/my-react-app/src/styles/pages/AddItemPage.styled.js b/react/my-react-app/src/styles/pages/AddItemPage.styled.js
new file mode 100644
index 00000000..d68dba25
--- /dev/null
+++ b/react/my-react-app/src/styles/pages/AddItemPage.styled.js
@@ -0,0 +1,41 @@
+import styled from "styled-components";
+import Button from "../../components/ui/Button";
+import { PageContainer, CommonContainer, FormHeader, Title, ErrorMessage as CommonErrorMessage } from "../common/CommonStyles";
+
+export { PageContainer, FormHeader, Title };
+
+export const SubmitButton = styled.button`
+ width: 100px;
+ margin-left: 16px;
+ padding: 8px 16px;
+ font-size: 16px;
+ font-weight: 600;
+ height: 40px;
+ border-radius: 8px;
+ border: none;
+ color: white;
+ cursor: pointer;
+ background-color: #3692FF;
+
+ &:disabled {
+ background-color: #B0B8C1;
+ cursor: not-allowed;
+ opacity: 0.7;
+ }
+`;
+
+export const FormSection = styled(CommonContainer)`
+ margin-bottom: 24px;
+`;
+
+export const ImageSection = styled(CommonContainer)`
+ margin-bottom: 30px;
+`;
+
+export const ImageLabel = styled.div`
+ font-weight: 600;
+ margin-bottom: 8px;
+ display: block;
+`;
+
+export const ErrorMessage = CommonErrorMessage;
diff --git a/react/my-react-app/src/styles/pages/ProductDetailPage.styled.js b/react/my-react-app/src/styles/pages/ProductDetailPage.styled.js
new file mode 100644
index 00000000..d4805231
--- /dev/null
+++ b/react/my-react-app/src/styles/pages/ProductDetailPage.styled.js
@@ -0,0 +1,45 @@
+import styled from "styled-components";
+import { PageContainer, CommonContainer, FormHeader, Title, Divider as CommonDivider } from "../common/CommonStyles";
+
+export { PageContainer, FormHeader, Title };
+
+export const ProductDetailContainer = styled.div`
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+`;
+
+export const LoadingSpinner = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+`;
+
+export const ErrorMessage = styled.div`
+ text-align: center;
+ color: #e53935;
+ padding: 20px;
+ margin-top: 100px;
+`;
+
+export const Divider = CommonDivider;
+
+export const ContentLayout = styled.div`
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 24px;
+ margin-top: 24px;
+
+ /* 태블릿 화면 */
+ @media (min-width: 768px) and (max-width: 1023px) {
+ grid-template-columns: 340px 1fr;
+ gap: 32px;
+ }
+
+ /* 데스크톱 화면 */
+ @media (min-width: 1024px) {
+ grid-template-columns: 486px 1fr;
+ gap: 48px;
+ }
+`;
diff --git a/react/my-react-app/src/pages/ItemsPage.styled.js b/react/my-react-app/src/styles/pages/ProductsPage.styled.js
similarity index 89%
rename from react/my-react-app/src/pages/ItemsPage.styled.js
rename to react/my-react-app/src/styles/pages/ProductsPage.styled.js
index 2d094950..f1fa20c2 100644
--- a/react/my-react-app/src/pages/ItemsPage.styled.js
+++ b/react/my-react-app/src/styles/pages/ProductsPage.styled.js
@@ -1,12 +1,15 @@
import styled from "styled-components";
import { Link } from "react-router-dom";
+import { PageContainer, CommonContainer, FormHeader, Title } from "../common/CommonStyles";
+
+// 공통 스타일 내보내기
+export { PageContainer, FormHeader, Title };
// Main container
-export const ItemsPageContainer = styled.div`
+export const ProductsPageContainer = styled.div`
max-width: 1200px;
margin: 0 auto;
- padding: 20px;
- padding-top: 100px;
+ padding: 0 30px 20px 20px;
`;
// Section containers
@@ -17,7 +20,8 @@ export const SectionContainer = styled.section`
export const SectionTitle = styled.h2`
font-size: 24px;
font-weight: bold;
- margin-bottom: 20px;
+ margin-bottom: 10px;
+ margin-left: 10px;
`;
// All items header
@@ -50,14 +54,12 @@ export const TitleRow = styled.div`
export const AllItemsTitle = styled.h2`
margin-bottom: 0;
- margin-right: 12px;
+ margin-right: 20px;
font-size: 24px;
font-weight: bold;
white-space: nowrap;
-
- @media (max-width: 767px) {
- font-size: 20px;
- }
+ flex-shrink: 0;
+ margin-left: 10px;
`;
export const HeaderControls = styled.div`
@@ -66,12 +68,14 @@ export const HeaderControls = styled.div`
align-items: center;
gap: 12px;
flex: 1;
- justify-content: flex-end;
+ justify-content: space-between;
+ width: 100%;
@media (max-width: 767px) {
width: 100%;
justify-content: space-between;
flex-wrap: nowrap;
+ margin-top: 12px;
}
`;
@@ -83,7 +87,7 @@ export const Group2 = styled.div`
justify-content: flex-end;
min-width: 0;
- @media (max-width: 1279px) {
+ @media (max-width: 1199px) {
justify-content: flex-start;
}
`;
@@ -229,14 +233,14 @@ export const ItemsGrid = styled.div`
`;
export const BestItemsGrid = styled(ItemsGrid)`
- grid-template-columns: 343px;
+ grid-template-columns: 344px;
justify-content: center;
- @media (min-width: 768px) {
- grid-template-columns: repeat(2, 343px);
+ @media (min-width: 768px) and (max-width: 1199px) {
+ grid-template-columns: repeat(2, 344px);
}
- @media (min-width: 1280px) {
+ @media (min-width: 1200px) {
grid-template-columns: repeat(4, 282px);
}
`;
@@ -249,17 +253,18 @@ export const AllItemsGrid = styled(ItemsGrid)`
margin-left: auto;
margin-right: auto;
justify-content: center;
+ padding-left: 10px;
@media (max-width: 767px) {
grid-template-columns: repeat(2, 1fr);
}
- @media (min-width: 768px) {
+ @media (min-width: 768px) and (max-width: 1199px) {
grid-template-columns: repeat(3, 221px);
- max-width: 1200px;
+ max-width: 1199px;
}
- @media (min-width: 1280px) {
+ @media (min-width: 1200px) {
grid-template-columns: repeat(5, 224px);
max-width: 1200px;
}
diff --git a/react/my-react-app/src/styles/pages/SigninPage.styled.js b/react/my-react-app/src/styles/pages/SigninPage.styled.js
new file mode 100644
index 00000000..a058b9bd
--- /dev/null
+++ b/react/my-react-app/src/styles/pages/SigninPage.styled.js
@@ -0,0 +1,24 @@
+import styled from "styled-components";
+
+export const AuthContainer = styled.div`
+ width: 100%;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ background-color: #fff;
+
+ form {
+ width: 100%;
+ max-width: 640px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .input-item {
+ width: 100%;
+ }
+`;
diff --git a/react/my-react-app/src/utils/dateUtils.js b/react/my-react-app/src/utils/dateUtils.js
new file mode 100644
index 00000000..58942237
--- /dev/null
+++ b/react/my-react-app/src/utils/dateUtils.js
@@ -0,0 +1,7 @@
+export const formatDate = (dateString) => {
+ const date = new Date(dateString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}.${month}.${day}`;
+};
diff --git a/react/my-react-app/src/utils/format.js b/react/my-react-app/src/utils/format.js
new file mode 100644
index 00000000..9aab874a
--- /dev/null
+++ b/react/my-react-app/src/utils/format.js
@@ -0,0 +1,11 @@
+export const formatPrice = (price) => {
+ return price.toLocaleString();
+};
+
+export const formatDate = (date) => {
+ const d = new Date(date);
+ const year = d.getFullYear();
+ const month = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${year}. ${month}. ${day}`;
+};
diff --git a/react/my-react-app/src/utils/timeFormat.js b/react/my-react-app/src/utils/timeFormat.js
new file mode 100644
index 00000000..8476dfa5
--- /dev/null
+++ b/react/my-react-app/src/utils/timeFormat.js
@@ -0,0 +1,37 @@
+/**
+ * 시간을 '~전' 형식으로 포맷팅하는 함수
+ * @param {string} dateString - 날짜 문자열
+ * @returns {string} 포맷팅된 시간 문자열
+ */
+export const formatTimeAgo = (dateString) => {
+ const now = new Date();
+ const date = new Date(dateString);
+ const diffInSeconds = Math.floor((now - date) / 1000);
+
+ if (diffInSeconds < 60) {
+ return "방금 전";
+ }
+
+ const diffInMinutes = Math.floor(diffInSeconds / 60);
+ if (diffInMinutes < 60) {
+ return `${diffInMinutes}분 전`;
+ }
+
+ const diffInHours = Math.floor(diffInMinutes / 60);
+ if (diffInHours < 24) {
+ return `${diffInHours}시간 전`;
+ }
+
+ const diffInDays = Math.floor(diffInHours / 24);
+ if (diffInDays < 30) {
+ return `${diffInDays}일 전`;
+ }
+
+ const diffInMonths = Math.floor(diffInDays / 30);
+ if (diffInMonths < 12) {
+ return `${diffInMonths}개월 전`;
+ }
+
+ const diffInYears = Math.floor(diffInMonths / 12);
+ return `${diffInYears}년 전`;
+};