diff --git a/react/my-react-app/src/components/auth/AuthFormField.tsx b/react/my-react-app/src/components/auth/AuthFormField.tsx new file mode 100644 index 00000000..57c4387d --- /dev/null +++ b/react/my-react-app/src/components/auth/AuthFormField.tsx @@ -0,0 +1,70 @@ +import React, { ChangeEvent, FocusEvent } from "react"; + +interface AuthFormFieldProps { + id: string; + label: string; + name: string; + type: "email" | "text" | "password"; + placeholder: string; + value: string; + onChange: (e: ChangeEvent) => void; + onBlur?: (e: FocusEvent) => void; // onBlur는 선택적 + error?: string; // 에러 메시지도 선택적 + showPasswordToggle?: boolean; // 비밀번호 표시/숨김 토글 여부 + showPassword?: boolean; // 현재 비밀번호 표시 상태 + onToggleShowPassword?: () => void; // 토글 함수 +} + +const AuthFormField: React.FC = ({ + id, + label, + name, + type, + placeholder, + value, + onChange, + onBlur, + error, + showPasswordToggle = false, + showPassword = false, + onToggleShowPassword, +}) => { + return ( +
+ +
+ {" "} + {/* 비밀번호 토글 아이콘을 위해 wrapper 추가 */} + + {showPasswordToggle && type === "password" && onToggleShowPassword && ( + {showPassword + )} +
+ {error &&

{error}

} +
+ ); +}; + +export default AuthFormField; diff --git a/react/my-react-app/src/components/auth/AuthLogoLink.tsx b/react/my-react-app/src/components/auth/AuthLogoLink.tsx new file mode 100644 index 00000000..ae8476d0 --- /dev/null +++ b/react/my-react-app/src/components/auth/AuthLogoLink.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { Link as RouterLink } from "react-router-dom"; + +// HomePage.tsx, SigninPage.tsx, SignupPage.tsx 에서 사용된 Link 타입 문제 임시 해결 +const Link: any = RouterLink; + +const AuthLogoLink: React.FC = () => { + return ( + + 판다마켓 홈 + + ); +}; + +export default AuthLogoLink; diff --git a/react/my-react-app/src/components/auth/AuthRedirectLink.tsx b/react/my-react-app/src/components/auth/AuthRedirectLink.tsx new file mode 100644 index 00000000..411b0d77 --- /dev/null +++ b/react/my-react-app/src/components/auth/AuthRedirectLink.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Link as RouterLink } from "react-router-dom"; + +// HomePage.tsx, SigninPage.tsx, SignupPage.tsx 에서 사용된 Link 타입 문제 임시 해결 +const Link: any = RouterLink; + +interface AuthRedirectLinkProps { + text: string; + linkText: string; + to: string; +} + +const AuthRedirectLink: React.FC = ({ + text, + linkText, + to, +}) => { + return ( +
+ {text} {linkText} +
+ ); +}; + +export default AuthRedirectLink; diff --git a/react/my-react-app/src/components/auth/SocialLoginButtons.tsx b/react/my-react-app/src/components/auth/SocialLoginButtons.tsx new file mode 100644 index 00000000..4cd3feee --- /dev/null +++ b/react/my-react-app/src/components/auth/SocialLoginButtons.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +const SocialLoginButtons: React.FC = () => { + return ( +
+

간편 로그인하기

+ +
+ ); +}; + +export default SocialLoginButtons; diff --git a/react/my-react-app/src/components/comment/CommentSection.jsx b/react/my-react-app/src/components/comment/CommentSection.jsx index d35011da..941de2ff 100644 --- a/react/my-react-app/src/components/comment/CommentSection.jsx +++ b/react/my-react-app/src/components/comment/CommentSection.jsx @@ -1,80 +1,14 @@ -import React from "react"; -import styled from "styled-components"; +import React, { useState } from "react"; 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; -`; +import { + CommentsContainer, + SectionTitle, + CommentForm, + CommentInput, + SubmitButton, + CommentList, + MessageParagraph, +} from "../../styles/components/comment/CommentSection.styled"; // 디폴트 문구 정의 const DEFAULT_COMMENT = @@ -82,45 +16,59 @@ const DEFAULT_COMMENT = function CommentSection({ comments, - newComment, loadingComments, commentError, - onCommentChange, - onCommentSubmit, - onCommentFocus, - onCommentBlur, - onToggleMenu + onToggleMenu, + handleCommentSubmit, + submittingComment, }) { + const [newComment, setNewComment] = useState(""); + + const handleCommentChange = (e) => { + setNewComment(e.target.value); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!newComment.trim() || submittingComment) return; + const success = await handleCommentSubmit(newComment); + if (success) { + setNewComment(""); + } + }; + return ( 문의하기 - + - 등록 + + {submittingComment ? "등록 중..." : "등록"} + {loadingComments ? ( 댓글을 불러오는 중... ) : commentError ? ( - - 댓글을 불러오는데 오류가 발생했습니다. - + {commentError} ) : comments.length === 0 ? ( 아직 댓글이 없습니다. 처음으로 댓글을 남겨보세요! ) : ( comments.map((comment) => ( - )) diff --git a/react/my-react-app/src/components/product/ProductDetails.jsx b/react/my-react-app/src/components/product/ProductDetails.jsx index fffc036f..c99b9756 100644 --- a/react/my-react-app/src/components/product/ProductDetails.jsx +++ b/react/my-react-app/src/components/product/ProductDetails.jsx @@ -1,14 +1,8 @@ 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; -`; +import { ProductDetailsContainer } from "../../styles/components/product/ProductDetails.styled"; function ProductDetails({ product, onFavoriteClick }) { return ( diff --git a/react/my-react-app/src/components/product/ProductImages.jsx b/react/my-react-app/src/components/product/ProductImages.jsx index 6418e166..6397090e 100644 --- a/react/my-react-app/src/components/product/ProductImages.jsx +++ b/react/my-react-app/src/components/product/ProductImages.jsx @@ -1,70 +1,24 @@ 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; - } -`; +import { + ImageContainer, + MainImage, + ThumbnailContainer, + Thumbnail, +} from "../../styles/components/product/ProductImages.styled"; function ProductImages({ images }) { const [activeIndex, setActiveIndex] = useState(0); - + // 이미지가 없을 경우 기본 이미지 사용 - const imageList = images && images.length > 0 - ? images - : ['/placeholder-image.jpg']; - + const imageList = + images && images.length > 0 ? images : ["/placeholder-image.jpg"]; + return (
- + - + {imageList.length > 1 && ( {imageList.map((image, index) => ( diff --git a/react/my-react-app/src/components/product/ProductInfo.jsx b/react/my-react-app/src/components/product/ProductInfo.jsx index dbf8655e..38f48db3 100644 --- a/react/my-react-app/src/components/product/ProductInfo.jsx +++ b/react/my-react-app/src/components/product/ProductInfo.jsx @@ -1,149 +1,44 @@ import React, { useState } from "react"; -import styled from "styled-components"; -import { formatPrice, formatDate } from "../../utils/format"; +import { formatPrice } from "../../utils/format"; +import { + Container, + Divider, + SectionTitle, + TitleContainer, + Title, + Price, + Description, + KebabMenuButton, + MenuDropdown, + MenuItem, +} from "../../styles/components/product/ProductInfo.styled"; -const Container = styled.div` - display: flex; - flex-direction: column; - gap: 16px; -`; +// 케밥 메뉴 아이콘 컴포넌트를 ProductInfo 컴포넌트 외부로 이동 +const KebabMenuIcon = () => ( + + + + + +); -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, -}) { +function ProductInfo({ name, price, description }) { 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 ( diff --git a/react/my-react-app/src/components/product/ProductTags.jsx b/react/my-react-app/src/components/product/ProductTags.jsx index a79840c2..bb95cb57 100644 --- a/react/my-react-app/src/components/product/ProductTags.jsx +++ b/react/my-react-app/src/components/product/ProductTags.jsx @@ -1,36 +1,10 @@ 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; -`; +import { + Container, + SectionTitle, + TagsContainer, + Tag, +} from "../../styles/components/product/ProductTags.styled"; function ProductTags({ tags }) { if (!tags || tags.length === 0) return null; diff --git a/react/my-react-app/src/components/product/SellerInfo.jsx b/react/my-react-app/src/components/product/SellerInfo.jsx index 9d9ae5bd..14097d3f 100644 --- a/react/my-react-app/src/components/product/SellerInfo.jsx +++ b/react/my-react-app/src/components/product/SellerInfo.jsx @@ -1,108 +1,39 @@ 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 }) { +import { formatDate } from "../../utils/format"; +import { + Container, + SectionTitle, + SellerProfile, + ProfileImage, + SellerInfoContainer, + SellerName, + SellerDate, + FavoriteButton, + FavoriteIcon, +} from "../../styles/components/product/SellerInfo.styled"; + +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'} + + {createdAt ? formatDate(createdAt) : "2024.01.02"} +
diff --git a/react/my-react-app/src/hooks/useComments.js b/react/my-react-app/src/hooks/useComments.js new file mode 100644 index 00000000..4f5581b5 --- /dev/null +++ b/react/my-react-app/src/hooks/useComments.js @@ -0,0 +1,99 @@ +import { useState, useEffect, useCallback } from "react"; +import { productAPI } from "../api/products"; +import { formatTimeAgo } from "../utils/timeFormat"; // 경로가 정확한지 확인 필요 + +function useComments(productId) { + const [comments, setComments] = useState([]); + const [loadingComments, setLoadingComments] = useState(false); // 초기값 false로 변경 + const [commentError, setCommentError] = useState(null); + const [submittingComment, setSubmittingComment] = useState(false); + + const fetchComments = useCallback(async () => { + if (!productId) return; + setLoadingComments(true); + setCommentError(null); + try { + const { data } = await productAPI.getComments(productId, null, 10); // page, pageSize는 필요에 따라 조정 + 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), // 또는 writer.image 사용 + showMenu: false, + })); + setComments(formattedComments); + } catch (err) { + setCommentError( + err.response?.data?.message || "댓글을 불러오는데 실패했습니다." + ); + } finally { + setLoadingComments(false); + } + }, [productId]); + + useEffect(() => { + // productId가 있을 때만 댓글을 가져오도록 수정 + if (productId) { + fetchComments(); + } + }, [productId, fetchComments]); + + const handleCommentSubmit = useCallback( + async (newCommentContent) => { + if (!newCommentContent.trim() || submittingComment || !productId) + return null; + setSubmittingComment(true); + try { + // API 호출하여 댓글 추가 + await productAPI.addComment(productId, { + content: newCommentContent, + }); + // 댓글 추가 성공 후, 댓글 목록을 다시 불러옴 + await fetchComments(); // 댓글 목록 새로고침 + return true; // 성공 여부 반환 (객체 대신 boolean 또는 void로 변경 가능) + } catch (err) { + console.error("Failed to add comment:", err); + alert( + err.response?.data?.message || + "댓글 작성에 실패했습니다. 다시 시도해주세요." + ); + return null; + } finally { + setSubmittingComment(false); + } + }, + [productId, submittingComment, fetchComments] // fetchComments 의존성 배열에 추가 + ); + + const handleToggleCommentMenu = (commentId) => { + setComments((prevComments) => + prevComments.map( + (comment) => + comment.id === commentId + ? { ...comment, showMenu: !comment.showMenu } + : { ...comment, showMenu: false } // 다른 메뉴는 닫도록 유지 + ) + ); + }; + + return { + comments, + loadingComments, + commentError, + submittingComment, + handleCommentSubmit, + handleToggleCommentMenu, + fetchComments, + }; +} +export default useComments; diff --git a/react/my-react-app/src/hooks/useProductDetail.js b/react/my-react-app/src/hooks/useProductDetail.js new file mode 100644 index 00000000..689c0572 --- /dev/null +++ b/react/my-react-app/src/hooks/useProductDetail.js @@ -0,0 +1,63 @@ +import { useState, useEffect, useCallback } from "react"; +import { productAPI } from "../api/products"; + +function useProductDetail(productId) { + const [product, setProduct] = useState(null); + const [loadingProduct, setLoadingProduct] = useState(true); + const [productError, setProductError] = useState(null); + + useEffect(() => { + const fetchProductData = async () => { + if (!productId) { + setLoadingProduct(false); // productId가 없으면 로딩 종료 + setProductError("상품 ID가 유효하지 않습니다."); // 에러 메시지 설정 또는 다른 처리 + return; + } + setLoadingProduct(true); + setProductError(null); + try { + const { data } = await productAPI.getDetail(productId); + setProduct(data); + } catch (err) { + setProductError( + err.response?.data?.message || "상품 정보를 불러오는데 실패했습니다." + ); + } finally { + setLoadingProduct(false); + } + }; + fetchProductData(); + }, [productId]); + + const handleFavoriteClick = useCallback(async () => { + if (!product) return; + try { + // API 응답 구조에 따라 isFavorite, favoriteCount 직접 사용 또는 data 객체에서 가져오기 + const api = product.isFavorite + ? productAPI.removeFavorite + : productAPI.addFavorite; + const { data } = await api(productId); + // 서버 응답에서 업데이트된 isFavorite와 favoriteCount를 받아 상태를 갱신합니다. + // API 응답이 전체 product 객체를 반환한다면 setProduct(data)를 사용할 수 있습니다. + // 그렇지 않다면, 부분적으로 상태를 업데이트해야 합니다. + setProduct((prevProduct) => ({ + ...prevProduct, + isFavorite: + data.isFavorite !== undefined + ? data.isFavorite + : prevProduct.isFavorite, + favoriteCount: + data.favoriteCount !== undefined + ? data.favoriteCount + : prevProduct.favoriteCount, + })); + } catch (err) { + console.error("Failed to toggle favorite:", err); + // 사용자에게 에러 알림 등을 추가할 수 있습니다. + } + }, [product, productId]); + + return { product, loadingProduct, productError, handleFavoriteClick }; +} + +export default useProductDetail; diff --git a/react/my-react-app/src/hooks/useSignupForm.ts b/react/my-react-app/src/hooks/useSignupForm.ts new file mode 100644 index 00000000..ddfcad0a --- /dev/null +++ b/react/my-react-app/src/hooks/useSignupForm.ts @@ -0,0 +1,190 @@ +import { useState, ChangeEvent, FormEvent, FocusEvent } from "react"; +import { useNavigate } from "react-router-dom"; + +export const useSignupForm = () => { + const [email, setEmail] = useState(""); + const [nickname, setNickname] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirmation, setPasswordConfirmation] = useState(""); + + const [emailError, setEmailError] = useState(""); + const [nicknameError, setNicknameError] = useState(""); + const [passwordError, setPasswordError] = useState(""); + const [passwordConfirmationError, setPasswordConfirmationError] = + useState(""); + + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirmation, setShowPasswordConfirmation] = + useState(false); + + const navigate = useNavigate(); + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + + const validateEmail = (): boolean => { + if (!email) { + setEmailError("이메일을 입력해주세요."); + return false; + } else if (!emailRegex.test(email)) { + setEmailError("잘못된 이메일 형식입니다"); + return false; + } + setEmailError(""); + return true; + }; + + const validateNickname = (): boolean => { + if (!nickname) { + setNicknameError("닉네임을 입력해주세요."); + return false; + } + setNicknameError(""); + return true; + }; + + const validatePassword = (): boolean => { + if (!password) { + setPasswordError("비밀번호를 입력해주세요."); + return false; + } else if (password.length < 8) { + setPasswordError("비밀번호를 8자 이상 입력해주세요."); + return false; + } + setPasswordError(""); + // 비밀번호 변경 시 비밀번호 확인 필드도 다시 검증 + if (passwordConfirmation) validatePasswordConfirmation(); + return true; + }; + + const validatePasswordConfirmation = (): boolean => { + if (!passwordConfirmation) { + setPasswordConfirmationError("비밀번호를 다시 한 번 입력해 주세요."); + return false; + } else if (password !== passwordConfirmation) { + setPasswordConfirmationError("비밀번호가 일치하지 않습니다."); + return false; + } + setPasswordConfirmationError(""); + return true; + }; + + const handleEmailChange = (e: ChangeEvent) => { + const { value } = e.target; + setEmail(value); + if (!value) setEmailError("이메일을 입력해주세요."); + else if (!emailRegex.test(value)) setEmailError("잘못된 이메일 형식입니다"); + else setEmailError(""); + }; + + const handleNicknameChange = (e: ChangeEvent) => { + const { value } = e.target; + setNickname(value); + if (!value) setNicknameError("닉네임을 입력해주세요."); + else setNicknameError(""); + }; + + const handlePasswordChange = (e: ChangeEvent) => { + const { value } = e.target; + setPassword(value); + if (!value) setPasswordError("비밀번호를 입력해주세요."); + else if (value.length < 8) + setPasswordError("비밀번호를 8자 이상 입력해주세요."); + else setPasswordError(""); + + // 비밀번호 확인 필드와 즉시 비교 + if (passwordConfirmation && value !== passwordConfirmation) { + setPasswordConfirmationError("비밀번호가 일치하지 않습니다."); + } else if (passwordConfirmation && value === passwordConfirmation) { + setPasswordConfirmationError(""); + } + }; + + const handlePasswordConfirmationChange = ( + e: ChangeEvent + ) => { + const { value } = e.target; + setPasswordConfirmation(value); + if (!value) + setPasswordConfirmationError("비밀번호를 다시 한 번 입력해 주세요."); + else if (password !== value) + setPasswordConfirmationError("비밀번호가 일치하지 않습니다."); + else setPasswordConfirmationError(""); + }; + + const toggleShowPassword = () => setShowPassword(!showPassword); + const toggleShowPasswordConfirmation = () => + setShowPasswordConfirmation(!showPasswordConfirmation); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const isEmailValid = validateEmail(); + const isNicknameValid = validateNickname(); + const isPasswordValid = validatePassword(); + const isPasswordConfirmationValid = validatePasswordConfirmation(); + + if ( + isEmailValid && + isNicknameValid && + isPasswordValid && + isPasswordConfirmationValid + ) { + console.log("Signup successful", { email, nickname, password }); + navigate("/signin"); + } else { + console.log("Signup failed: Invalid input"); + } + }; + + const isFormValid: boolean = + !!email && + !!nickname && + !!password && + !!passwordConfirmation && + !emailError && + !nicknameError && + !passwordError && + !passwordConfirmationError && + password.length >= 8 && + password === passwordConfirmation && + emailRegex.test(email); + + return { + formData: { + email, + nickname, + password, + passwordConfirmation, + }, + errors: { + emailError, + nicknameError, + passwordError, + passwordConfirmationError, + }, + visibility: { + showPassword, + showPasswordConfirmation, + }, + handlers: { + handleEmailChange, + handleNicknameChange, + handlePasswordChange, + handlePasswordConfirmationChange, + validateEmail: validateEmail as ( + e?: FocusEvent + ) => boolean, // onBlur 타입 호환성 위해 수정 + validateNickname: validateNickname as ( + e?: FocusEvent + ) => boolean, + validatePassword: validatePassword as ( + e?: FocusEvent + ) => boolean, + validatePasswordConfirmation: validatePasswordConfirmation as ( + e?: FocusEvent + ) => boolean, + toggleShowPassword, + toggleShowPasswordConfirmation, + handleSubmit, + }, + isFormValid, + }; +}; diff --git a/react/my-react-app/src/pages/AddItemPage.jsx b/react/my-react-app/src/pages/AddItemPage.jsx index 9ecff43d..6692f249 100644 --- a/react/my-react-app/src/pages/AddItemPage.jsx +++ b/react/my-react-app/src/pages/AddItemPage.jsx @@ -13,6 +13,7 @@ import { ImageSection, ImageLabel, ErrorMessage, + SubmitButton, } from "../styles/pages/AddItemPage.styled"; // 직접 버튼 컴포넌트 생성 @@ -64,35 +65,21 @@ function AddItemPage() { }; // 텍스트 입력 핸들러 - const handleTextChange = (e, field) => { - let value = e.target.value; - - if (field === "price") { - // 숫자가 아닌 문자가 입력되었는지 확인 - if (/[^0-9]/.test(value)) { + const handleTextChange = (e) => { + const { name, value } = e.target; + let processedValue = value; + + if (name === "price") { + // 숫자가 아닌 문자 제거 (정규식 사용) + processedValue = value.replace(/[^0-9]/g, ""); + if (/[^0-9]/.test(value) && 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(""); - } + setFormData({ ...formData, [name]: processedValue }); }; // 태그 추가 핸들러 @@ -117,6 +104,7 @@ function AddItemPage() { if (validateForm()) { alert("상품이 등록되었습니다! (API 연동 전)"); console.log("Form submitted with data:", formData); + // TODO: API 연동 } else { alert("모든 필드를 입력해주세요."); } @@ -130,9 +118,9 @@ function AddItemPage() {
상품 등록하기 - + 등록 - + @@ -148,9 +136,10 @@ function AddItemPage() { handleTextChange(e, "title")} + onChange={handleTextChange} maxLength={40} /> @@ -158,9 +147,10 @@ function AddItemPage() {