diff --git a/src/components/Banner/Banner.tsx b/src/components/Banner/Banner.tsx new file mode 100644 index 00000000..b3a0020b --- /dev/null +++ b/src/components/Banner/Banner.tsx @@ -0,0 +1,107 @@ +/** @jsxImportSource @emotion/react */ +import { ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import Button from "@/components/ui/Button"; +import BannerStyle from "./BannerStyle"; + +interface BannerInfoProps { + align: string; + children: ReactNode; +} + +const BannerInfo = ({ children, align }: BannerInfoProps) => { + return
{children}
; +}; + +interface BannerButtonProps { + linkTo: string; + children: ReactNode; + ariaLabel?: string; +} + +const BannerButton = ({ + linkTo, + children = "구경하러 가기", + ariaLabel, +}: BannerButtonProps) => { + const navigate = useNavigate(); + + return ( + + ); +}; + +interface BannerImageProps { + imgSrc: string; + imgAlt: string; + lazyLoading?: boolean; + align?: string; +} + +const BannerImage = ({ + imgSrc, + imgAlt, + lazyLoading, + align, +}: BannerImageProps) => { + return ( + {imgAlt} + ); +}; + +interface BannerProps { + title: string | ReactNode; + imgSrc: string; + imgAlt: string; + linkTo?: string; + ariaLabel?: string; + lazyLoading?: boolean; + infoAlign?: string; + imgAlign?: string; +} + +const Banner = ({ + title, + linkTo, + imgSrc, + imgAlt, + ariaLabel, + lazyLoading, + infoAlign = "left", + imgAlign = "right", +}: BannerProps) => { + return ( +
+
+ +

{title}

+ {linkTo && 구경하러 가기} +
+ +
+
+ ); +}; + +Banner.Info = BannerInfo; +Banner.Button = BannerButton; +Banner.Image = BannerImage; + +export default Banner; diff --git a/src/components/Banner/BannerStyle.ts b/src/components/Banner/BannerStyle.ts new file mode 100644 index 00000000..580d2a5e --- /dev/null +++ b/src/components/Banner/BannerStyle.ts @@ -0,0 +1,126 @@ +/** @jsxImportSource @emotion/react */ +import { css } from "@emotion/react"; +import { BREAKPOINTS } from "@/constants/responsive"; +import { COLORS } from "@/styles/colors"; +import { FONT_SIZES } from "@/styles/fontSizes"; + +const BannerStyle = css` + min-height: 540px; + background: ${COLORS.background.blue}; + color: ${COLORS.gray[700]}; + text-align: center; + + .banner-container { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + min-height: 540px; + } + + .banner-hero { + background: ${COLORS.background.lightBlue}; + } + + .banner-title { + margin-bottom: 18px; + word-break: keep-all; + + @media (min-width: ${BREAKPOINTS.tablet}px) { + margin-bottom: 24px; + } + + @media (min-width: ${BREAKPOINTS.desktop}px) { + margin-bottom: 32px; + } + } + + .banner-info { + padding-top: 120px; + } + + .banner-hero .banner-info { + display: flex; + flex-direction: column; + align-items: center; + margin: 0 auto; + padding-top: 48px; + max-width: 240px; + } + + .banner-info .btn-lg { + display: block; + width: 100%; + font-size: ${FONT_SIZES[18]}; + line-height: 24px; + max-width: 356px; + } + + .banner-img { + width: 100%; + max-width: 744px; + margin: 0 auto; + } + + @media (min-width: 640px) { + .banner-hero .banner-info { + padding: 84px 0 210px; + max-width: none; + } + } + + @media (min-width: ${BREAKPOINTS.tablet}px) { + .banner { + height: 926px; + } + .banner.banner-hero { + height: 770px; + } + + .banner-info .btn-lg { + line-height: 32px; + font-size: ${FONT_SIZES[20]}; + } + } + + @media (min-width: ${BREAKPOINTS.desktop}px) { + .banner-container { + width: var(--container-width); + margin: 0 auto; + } + + .banner, + .banner.banner-hero { + height: 540px; + } + + .banner-hero .banner-info { + align-items: flex-start; + } + + .banner-container { + flex-direction: row; + justify-content: center; + align-items: flex-end; + } + + .banner-info { + padding-bottom: 10.75rem; + } + .banner-hero .banner-info { + padding-bottom: 6.25rem; + } + + .banner-title { + text-align: left; + } + } + + @supports (font-size: clamp(1rem, 2vw, 3rem)) { + .banner-title { + font-size: clamp(32px, 5vw, 40px); + } + } +`; + +export default BannerStyle; diff --git a/src/components/Form/LoginForm.tsx b/src/components/Form/LoginForm.tsx new file mode 100644 index 00000000..9b2943f6 --- /dev/null +++ b/src/components/Form/LoginForm.tsx @@ -0,0 +1,89 @@ +/** @jsxImportSource @emotion/react */ +import FormStyle from "./FormStyle"; +import { Link } from "react-router-dom"; +import Button from "@/components/ui/Button"; +import SocialLogin from "@/components/SocialLogin/SocialLogin"; +import logoImg from "@/assets/images/logo.svg"; +import loginUser from "@/services/post/loginUser"; +import InputField from "../ui/Form/InputField"; +import PasswordField from "../ui/Form/PasswordField"; +import useAuthForm from "@/hooks/useAuthForm"; +import { renderButtonTextByState } from "@/utils/renderButtonText"; + +const LoginForm = () => { + const { + formRef, + isSubmitting, + submitError, + handleBlur, + validateForm, + isFormValid, + fieldErrors, + handleSubmit, + } = useAuthForm({ + onSubmit: async (userData) => { + await loginUser(userData); + }, + }); + + return ( +
+
+ + 판다마켓 로고 + +
+ +
+ + + + + + {submitError &&

{`${submitError}`}

} + + + +
+ 판다마켓이 처음이신가요? + + 회원가입 + +
+
+
+ ); +}; + +export default LoginForm; diff --git a/src/components/Form/SignUpForm.tsx b/src/components/Form/SignUpForm.tsx new file mode 100644 index 00000000..e9c8cfc4 --- /dev/null +++ b/src/components/Form/SignUpForm.tsx @@ -0,0 +1,107 @@ +/** @jsxImportSource @emotion/react */ +import { Link } from "react-router-dom"; +import logoImg from "@/assets/images/logo.svg"; +import Button from "@/components/ui/Button"; +import SocialLogin from "@/components/SocialLogin/SocialLogin"; +import InputField from "@/components/ui/Form/InputField"; +import PasswordField from "@/components/ui/Form/PasswordField"; +import FormStyle from "./FormStyle"; +import useAuthForm from "@/hooks/useAuthForm"; +import createUser from "@/services/post/createUser"; +import { renderButtonTextByState } from "@/utils/renderButtonText"; + +const SignUpForm = () => { + const { + formRef, + isSubmitting, + submitError, + handleBlur, + validateForm, + isFormValid, + fieldErrors, + handleSubmit, + } = useAuthForm({ + onSubmit: async (userData) => { + await createUser(userData); + }, + }); + + return ( +
+
+ + 판다마켓 로고 + +
+ +
+ + + + + + + + {submitError &&

{`${submitError}`}

} + + + +
+ 이미 회원이신가요? + + 로그인 + +
+
+
+ ); +}; + +export default SignUpForm; diff --git a/src/components/Section/MainSection.tsx b/src/components/Section/MainSection.tsx new file mode 100644 index 00000000..4388e8c3 --- /dev/null +++ b/src/components/Section/MainSection.tsx @@ -0,0 +1,46 @@ +/** @jsxImportSource @emotion/react */ +import { ReactNode } from "react"; +import MainSectionStyle from "./MainSectionStyle"; +import { BREAKPOINTS } from "@/constants/responsive"; + +interface MainSectionProps { + title: string | ReactNode; + label?: string; + ariaLabel?: string; + description?: string | ReactNode; + imgSrc: string; + imgMobileSrc?: string; + reverse?: boolean; +} + +const MainSection = ({ + title, + label, + ariaLabel, + description, + imgSrc, + imgMobileSrc, + reverse = false, +}: MainSectionProps) => { + return ( +
+
+ 두 마리의 팬더가 인기 상품인 초록색 티셔츠를 보고 있는 일러스트 +
+
{label}
+

{title}

+

{description}

+
+
+
+ ); +}; + +export default MainSection; diff --git a/src/components/Section/MainSectionStyle.ts b/src/components/Section/MainSectionStyle.ts new file mode 100644 index 00000000..19bd3854 --- /dev/null +++ b/src/components/Section/MainSectionStyle.ts @@ -0,0 +1,103 @@ +/** @jsxImportSource @emotion/react */ +import { css } from "@emotion/react"; +import { BREAKPOINTS } from "@/constants/responsive"; + +const MainSectionStyle = ({ reverse }: { reverse: boolean }) => css` + display: flex; + justify-content: center; + background: #fff; + margin-bottom: 40px; + + .section-container { + display: flex; + justify-content: center; + align-items: flex-start; + flex-direction: column; + gap: 24px; + flex-grow: 1; + } + + .section-img { + width: 100%; + } + + .section-info { + color: var(--gray700); + } + + .section-label { + margin-bottom: 8px; + font-size: var(--label-font-size); + font-weight: 700; + color: var(--primary-color); + } + + .section-title { + margin-bottom: var(--section-margin-bottom); + font-size: var(--heading-font-size); + word-break: keep-all; + } + + .section-desc { + font-size: var(--description-font-size); + } + + ${reverse && + css` + .section-container { + align-items: flex-end; + } + .section-info { + text-align: right; + } + `} + + @media (min-width: 640px) { + :root { + --sections-padding: 24px 24px 56px; + --section-margin-bottom: 24px; + } + } + + @media (min-width: ${BREAKPOINTS.tablet}px) { + .section-container { + flex-grow: 0; + } + } + + @media (min-width: ${BREAKPOINTS.desktop}px) { + :root { + --sections-padding: 138px 24px; + --section-margin-bottom: 1.5rem; + } + + padding: 0 24px 138px; + + .section-container { + align-items: center; + flex-direction: row; + width: var(--container-width-small); + gap: 4rem; + background: var(--background-light); + border-radius: var(--border-radius-md); + overflow: hidden; + padding: 0 1.5rem; + } + + ${reverse && + css` + .section-container { + align-items: center; + } + .section-container img { + order: 2; + } + `} + + .section-container img { + max-width: 50%; + } + } +`; + +export default MainSectionStyle; diff --git a/src/components/SocialLogin/SocialLogin.tsx b/src/components/SocialLogin/SocialLogin.tsx new file mode 100644 index 00000000..f2e3484e --- /dev/null +++ b/src/components/SocialLogin/SocialLogin.tsx @@ -0,0 +1,52 @@ +/** @jsxImportSource @emotion/react */ +import { css } from "@emotion/react"; +import googleIcon from "@/assets/images/ic_google.png"; +import kakaoIcon from "@/assets/images/ic_kakao.png"; +import SocialLoginButton from "./SocialLoginButton"; + +const SocialLogin = () => { + return ( +
+ 간편 로그인하기 +
+ + +
+
+ ); +}; + +export default SocialLogin; + +const SocialLoginStyle = css` + display: flex; + justify-content: space-between; + align-items: center; + margin: 8px 0; + padding: 16px 24px; + border-radius: var(--border-radius-xs); + background: var(--background-blue-light); + font-size: 16px; + color: var(--gray800); + + .social-login-icons { + display: flex; + gap: 1rem; + } + + @media (min-width: 640px) { + margin: 0; + } +`; diff --git a/src/components/SocialLogin/SocialLoginButton.tsx b/src/components/SocialLogin/SocialLoginButton.tsx new file mode 100644 index 00000000..9d0abaa9 --- /dev/null +++ b/src/components/SocialLogin/SocialLoginButton.tsx @@ -0,0 +1,35 @@ +/** @jsxImportSource @emotion/react */ +import { css } from "@emotion/react"; + +interface SocialLoginButtonProps { + href: string; + title?: string; + ariaLabel?: string; + imgSrc: string; + imgAlt: string; +} + +const SocialLoginButton = ({ + href, + title, + ariaLabel, + imgSrc, + imgAlt, +}: SocialLoginButtonProps) => { + return ( + + {imgAlt} + + ); +}; + +export default SocialLoginButton; + +const SocialLoginButtonStyle = css` + border-radius: 50%; +`; diff --git a/src/components/ui/Footer/FooterStyle.js b/src/components/ui/Footer/FooterStyle.js index 1e7354f9..075b5312 100644 --- a/src/components/ui/Footer/FooterStyle.js +++ b/src/components/ui/Footer/FooterStyle.js @@ -8,6 +8,8 @@ const FooterStyle = css` .footer-container { display: flex; justify-content: space-between; + max-width: var(--container-width); + margin: 0 auto; padding: var(--footer-padding); flex-wrap: wrap; gap: 24px; diff --git a/src/components/ui/Form/InputField.tsx b/src/components/ui/Form/InputField.tsx new file mode 100644 index 00000000..582fdb99 --- /dev/null +++ b/src/components/ui/Form/InputField.tsx @@ -0,0 +1,39 @@ +/** @jsxImportSource @emotion/react */ +import Input from "@/components/ui/Input"; +import FormControl from "./FormControl"; +import { FormField } from "@/types/form"; + +const InputField = ({ + label, + inputId, + type, + name, + placeholder, + required, + onBlur, + fieldError, +}: FormField) => { + return ( + + +
+ + {fieldError} +
+
+ ); +}; + +export default InputField; diff --git a/src/components/ui/Form/PasswordField.tsx b/src/components/ui/Form/PasswordField.tsx new file mode 100644 index 00000000..6c01a42d --- /dev/null +++ b/src/components/ui/Form/PasswordField.tsx @@ -0,0 +1,58 @@ +/** @jsxImportSource @emotion/react */ +import { useState } from "react"; +import Input from "@/components/ui/Input"; +import FormControl from "./FormControl"; +import { FormField } from "@/types/form"; +import IconButton from "@/components/ui/Button/IconButton"; +import eyeImg from "@/assets/images/ic_visibility_on.svg"; +import eyeCloseImg from "@/assets/images/ic_visibility_off.svg"; + +const PasswordField = ({ + label, + inputId, + name, + placeholder, + onBlur, + fieldError, +}: FormField) => { + const [isVisible, setIsVisible] = useState(false); + + return ( + + +
+
+ + setIsVisible((prev) => !prev)} + /> +
+ {fieldError} +
+
+ ); +}; + +export default PasswordField; diff --git a/src/hooks/useAuthForm.ts b/src/hooks/useAuthForm.ts new file mode 100644 index 00000000..98801389 --- /dev/null +++ b/src/hooks/useAuthForm.ts @@ -0,0 +1,67 @@ +import { useState, useRef } from "react"; +import useSignIn from "./useSignIn"; +import useForm from "./useForm"; +import { ReqData } from "@/types/form"; + +interface UseAuthFormProps { + onSubmit: (data: ReqData) => Promise; +} + +const useAuthForm = ({ onSubmit }: UseAuthFormProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const signIn = useSignIn(); + + const formRef = useRef(null); + + const { handleBlur, validateForm, isFormValid, fieldErrors } = + useForm(formRef); + + const handleSubmit = async () => { + const form = formRef.current; + if (!form) return; + + const formData = new FormData(form); + const userData: ReqData = {}; + + for (const [key, value] of formData.entries()) { + userData[key] = value; + } + + try { + setIsSubmitting(true); + setSubmitError(null); + await onSubmit(userData); + signIn(); // 로그인 컨텍스트 처리 -> 상품 목록 페이지로 이동 + } catch (err) { + if (err instanceof Error) { + setSubmitError(err); + } else { + setSubmitError(new Error("알 수 없는 오류가 발생했습니다.")); + } + } finally { + setIsSubmitting(false); + } + }; + + return { + // 대상 폼 + formRef, + + // 로딩, 에러 상태 + isSubmitting, + submitError, + + // 유효성 검사 + handleBlur, + validateForm, + isFormValid, + fieldErrors, + + // 폼 + handleSubmit, + }; +}; + +export default useAuthForm; diff --git a/src/pages/auth/AuthPageStyle.ts b/src/pages/auth/AuthPageStyle.ts index 37608e1c..049bb03a 100644 --- a/src/pages/auth/AuthPageStyle.ts +++ b/src/pages/auth/AuthPageStyle.ts @@ -1,20 +1,22 @@ /** @jsxImportSource @emotion/react */ import { css } from "@emotion/react"; import { BREAKPOINTS } from "@/constants/responsive"; -import FormStyle from "@/components/Form/FormStyle"; const AuthPageStyle = css` - ${FormStyle}; - - /*================ 로그인, 회원가입 ================*/ .form { max-width: 400px; padding: 0 16px; } .form-logo { - width: 198px; + display: flex; + justify-content: center; + width: 100%; margin: 0 0 2.5rem; + + img { + max-width: 198px; + } } .form-footer { @@ -32,48 +34,24 @@ const AuthPageStyle = css` font-size: 14px; } - /* 로그인 */ .login .form-container { padding: 80px 0; } - /* 회원가입 */ .signup .form-container { padding-top: var(--form-padding-top); padding-bottom: 178px; } - /* 간편 로그인 */ - .easy-login { - display: flex; - justify-content: space-between; - align-items: center; - margin: 8px 0; - padding: 16px 24px; - border-radius: var(--border-radius-xs); - background: var(--background-blue-light); - font-size: 16px; - color: var(--gray800); - } - - .easy-login-icons { - display: flex; - gap: 1rem; - } - - /*================ 반응형 ================*/ - /* Tablet */ @media (min-width: 640px) { .form { max-width: 640px; } .form-logo { - width: 396px; - } - - .easy-login { - margin: 0; + img { + max-width: 396px; + } } } diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index add81370..008736af 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -1,184 +1,14 @@ /** @jsxImportSource @emotion/react */ -import { useRef, useState } from "react"; -import { Link } from "react-router-dom"; import AuthContent from "@/components/layout/AuthContent"; import AuthPageStyle from "./AuthPageStyle"; -import Button from "@/components/ui/Button"; -import googleIcon from "@/assets/images/ic_google.png"; -import kakaoIcon from "@/assets/images/ic_kakao.png"; -import logoImg from "@/assets/images/logo.svg"; -import useForm from "@/hooks/useForm"; -import Input from "@/components/ui/Input"; -import FormControl from "@/components/ui/Form/FormControl"; -import IconButton from "@/components/ui/Button/IconButton"; -import eyeImg from "@/assets/images/ic_visibility_on.svg"; -import eyeCloseImg from "@/assets/images/ic_visibility_off.svg"; -import { ReqData } from "@/types/form"; -import loginUser from "@/services/post/loginUser"; -import useSignIn from "@/hooks/useSignIn"; +import LoginForm from "@/components/Form/LoginForm"; const LoginPage = () => { - const [isVisible, setIsVisible] = useState(false); - const [isSigningIn, setIsSigningIn] = useState(false); - const [isSignInError, setIsSignInError] = useState(null); - - const formRef = useRef(null); - - const signIn = useSignIn(); - - const { handleBlur, validateForm, isFormValid, fieldErrors } = - useForm(formRef); - - const handleLogin = async () => { - const form = formRef.current; - if (!form) return; - - const formData = new FormData(form); - const userData: ReqData = {}; - - for (const [key, value] of formData.entries()) { - userData[key] = value; - } - - try { - setIsSigningIn(true); - setIsSignInError(null); - await loginUser(userData); - signIn(); // 로그인 컨텍스트 처리 -> 상품 목록 페이지로 이동 - } catch (err) { - if (err instanceof Error) { - setIsSignInError(err); - } else { - setIsSignInError(new Error("알 수 없는 오류가 발생했습니다.")); - } - } finally { - setIsSigningIn(false); - } - }; - return (
-
-
- - 판다마켓 로고 - -
- -
- - -
- - {fieldErrors.email} -
-
- - - -
-
- - setIsVisible((prev) => !prev)} - /> -
- - {fieldErrors.password} - -
-
- - - {isSignInError &&

{`${isSignInError}`}

} -
- 간편 로그인하기 - -
-
- 판다마켓이 처음이신가요? - - 회원가입 - -
-
-
+
diff --git a/src/pages/auth/SignUpPage.tsx b/src/pages/auth/SignUpPage.tsx index 36f18f4e..599111f0 100644 --- a/src/pages/auth/SignUpPage.tsx +++ b/src/pages/auth/SignUpPage.tsx @@ -1,255 +1,14 @@ /** @jsxImportSource @emotion/react */ -import { useRef, useState } from "react"; -import { Link } from "react-router-dom"; -import useForm from "@/hooks/useForm"; -import Input from "@/components/ui/Input"; -import FormControl from "@/components/ui/Form/FormControl"; -import logoImg from "@/assets/images/logo.svg"; -import Button from "@/components/ui/Button"; import AuthContent from "@/components/layout/AuthContent"; import AuthPageStyle from "./AuthPageStyle"; -import googleIcon from "@/assets/images/ic_google.png"; -import kakaoIcon from "@/assets/images/ic_kakao.png"; -import IconButton from "@/components/ui/Button/IconButton"; -import eyeImg from "@/assets/images/ic_visibility_on.svg"; -import eyeCloseImg from "@/assets/images/ic_visibility_off.svg"; -import createUser from "@/services/post/createUser"; -import { ReqData } from "@/types/form"; -import useSignIn from "@/hooks/useSignIn"; +import SignUpForm from "@/components/Form/SignUpForm"; const SignUpPage = () => { - const [isVisible, setIsVisible] = useState(false); - const [isSigningUp, setIsSigningUp] = useState(false); - const [isSignUpError, setIsSignUpError] = useState(null); - - const formRef = useRef(null); - const { - handleBlur, - isFormValid, - validateForm, - fieldErrors, - // emailMsg, - // passwordMsg, - // passwordCheckMsg, - // nicknameMsg, - } = useForm(formRef); - - const signIn = useSignIn(); - - const handleSignUp = async () => { - const form = formRef.current; - if (!form) return; - - const formData = new FormData(form); - const userData: ReqData = {}; - - for (const [key, value] of formData.entries()) { - const mappedKey = key === "passwordCheck" ? "passwordConfirmation" : key; - userData[mappedKey] = value; - } - - try { - setIsSigningUp(true); - setIsSignUpError(null); - await createUser(userData); - signIn(); // 로그인 컨텍스트 처리 -> 상품 목록 페이지로 이동 - } catch (err) { - if (err instanceof Error) { - setIsSignUpError(err); - } else { - setIsSignUpError(new Error("알 수 없는 오류가 발생했습니다.")); - } - } finally { - setIsSigningUp(false); - } - }; - return (
-
-
- - 판다마켓 로고 - -
- -
- - -
- - {fieldErrors.email} -
-
- - - -
- - - {fieldErrors.nickname} - -
-
- - - -
-
- - setIsVisible((prev) => !prev)} - /> -
- - {fieldErrors.password} - -
-
- - - -
-
- - setIsVisible((prev) => !prev)} - /> -
- - {fieldErrors.passwordCheck} - -
-
- - - {isSignUpError &&

{`${isSignUpError}`}

} -
- 간편 로그인하기 - -
-
- 이미 회원이신가요? - - 로그인 - -
-
-
+
diff --git a/src/pages/main/MainPage.tsx b/src/pages/main/MainPage.tsx index f789aeba..57b87bdc 100644 --- a/src/pages/main/MainPage.tsx +++ b/src/pages/main/MainPage.tsx @@ -1,8 +1,6 @@ /** @jsxImportSource @emotion/react */ -import { useNavigate } from "react-router-dom"; import MainPageStyle from "./MainPageStyle.ts"; import Footer from "@/components/ui/Footer"; -import Button from "@/components/ui/Button"; import HomeTopImg from "@/assets/images/Img_home_top.png"; import HomeBottomImg from "@/assets/images/Img_home_bottom.png"; import HomeImg1 from "@/assets/images/Img_home_01.png"; @@ -11,127 +9,83 @@ import HomeImg2 from "@/assets/images/Img_home_02.png"; import HomeImg2Small from "@/assets/images/Img_home_02_sm.png"; import HomeImg3 from "@/assets/images/Img_home_03.png"; import HomeImg3Small from "@/assets/images/Img_home_03_sm.png"; -import { BREAKPOINTS } from "@/constants/responsive"; +import Banner from "@/components/Banner/Banner.tsx"; +import MainSection from "@/components/Section/MainSection.tsx"; const MainPage = () => { - const navigate = useNavigate(); - return ( - <> -
-
-
-
-

일상의 모든 물건을 거래해 보세요

- -
- 팬더가 파란 장바구니를 메고 마을 가운데에 서있는 일러스트 -
-
-
-
-
- 두 마리의 팬더가 인기 상품인 초록색 티셔츠를 보고 있는 일러스트 -
-
Hot item
-

인기 상품을 확인해 보세요

-

- 가장 HOT한 중고거래 물품을 -
- 판다 마켓에서 확인해 보세요 -

-
-
-
-
-
- 돋보기로 상품들 중 가운데 상품을 확대해서 보는 일러스트 -
-
Search
-

- 구매를 원하는 상품을 검색하세요 -

-

- 구매하고 싶은 물품은 검색해서 -
- 쉽게 찾아보세요 -

-
-
-
-
-
- 아래에 폴더들이 있고, 그 위에 마법봉으로 가운데의 연필꽂이, 공책, 하트 프레임 안경을 가리키는 일러스트 -
-
Register
-

- 판매를 원하는 상품을 등록하세요 -

-

- 어떤 물건이든 판매하고 싶은 상품을 -
- 쉽게 등록하세요 -

-
-
-
-
-
-
-
-

- 믿을 수 있는 -
- 판다마켓 중고 거래 -

-
- 팬더 두 마리가 파란 장바구니를 메고 서로 상품 후기를 주고받는 일러스트 -
-
-
+
+ + +
+ + 가장 HOT한 중고거래 물품을 +
+ 판다 마켓에서 확인해 보세요 + + } + imgSrc={HomeImg1} + imgMobileSrc={HomeImg1Small} + /> + + + 구매하고 싶은 물품은 검색해서 +
+ 쉽게 찾아보세요 + + } + imgSrc={HomeImg2} + imgMobileSrc={HomeImg2Small} + /> + + + 어떤 물건이든 판매하고 싶은 상품을 +
+ 쉽게 등록하세요 + + } + imgSrc={HomeImg3} + imgMobileSrc={HomeImg3Small} + /> +
+ + + 믿을 수 있는 +
+ 판다마켓 중고 거래 + + } + imgSrc={HomeBottomImg} + imgAlt="팬더 두 마리가 파란 장바구니를 메고 서로 상품 후기를 주고받는 일러스트" + ariaLabel="하단 배너" + lazyLoading={true} + />
- +
); }; diff --git a/src/pages/main/MainPageStyle.ts b/src/pages/main/MainPageStyle.ts index e1fe47c7..eb7b3c25 100644 --- a/src/pages/main/MainPageStyle.ts +++ b/src/pages/main/MainPageStyle.ts @@ -1,230 +1,11 @@ /** @jsxImportSource @emotion/react */ -import { BREAKPOINTS } from "@/constants/responsive"; import { css } from "@emotion/react"; const MainPageStyle = css` - /* section */ .sections { padding: var(--sections-padding); background: #fff; } - - .section { - display: flex; - justify-content: center; - background: #fff; - margin-bottom: 40px; - } - - .section-container { - display: flex; - justify-content: center; - align-items: flex-start; - flex-direction: column; - gap: 24px; - flex-grow: 1; - } - .section-search .section-container { - align-items: flex-end; - } - - .section-img { - width: 100%; - } - - .section-info { - color: var(--gray700); - } - - .section-search .section-info { - text-align: right; - } - - .section-label { - margin-bottom: 8px; - font-size: var(--label-font-size); - font-weight: 700; - color: var(--primary-color); - } - - .section-title { - margin-bottom: var(--section-margin-bottom); - font-size: var(--heading-font-size); - word-break: keep-all; - } - - .section-desc { - font-size: var(--description-font-size); - } - - /* banner */ - .banner { - min-height: 540px; - background: var(--background-blue); - color: var(--gray700); - text-align: center; - } - - .banner-container { - display: flex; - flex-direction: column; - justify-content: space-between; - height: 100%; - min-height: 540px; - } - - .banner-hero { - background: var(--background-blue-light); - } - - .banner-title { - font-size: var(--banner-font-size); - margin-bottom: 18px; - word-break: keep-all; - - @media (min-width: ${BREAKPOINTS.tablet}px) { - margin-bottom: 24px; - } - - @media (min-width: ${BREAKPOINTS.desktop}px) { - margin-bottom: 32px; - } - } - - .banner-info { - padding-top: 120px; - } - - .banner-hero .banner-info { - display: flex; - flex-direction: column; - align-items: center; - margin: 0 auto; - padding-top: 48px; - max-width: 240px; - } - - .banner-info .btn-lg { - display: block; - width: 100%; - font-size: var(--banner-btn-font-size); - line-height: 24px; - max-width: 356px; - } - - .banner-img { - width: 100%; - max-width: 744px; - margin: 0 auto; - } - - /*================ 반응형 ================*/ - /* Tablet */ - @media (min-width: 640px) { - :root { - --sections-padding: 24px 24px 56px; - --section-margin-bottom: 24px; - } - - .banner-hero .banner-info { - padding: 84px 0 210px; - max-width: none; - } - } - - @media (min-width: ${BREAKPOINTS.tablet}px) { - :root { - --banner-btn-font-size: 20px; - } - - .banner { - height: 926px; - } - .banner.banner-hero { - height: 770px; - } - - .banner-info .btn-lg { - line-height: 32px; - } - - .section-container { - flex-grow: 0; - } - } - - /* PC */ - @media (min-width: ${BREAKPOINTS.desktop}px) { - :root { - --sections-padding: 138px 24px; - --section-margin-bottom: 1.5rem; - } - - .banner-container { - width: var(--container-width); - margin: 0 auto; - } - - .banner, - .banner.banner-hero { - height: 540px; - } - - .banner-hero .banner-info { - align-items: flex-start; - } - - .banner-container { - flex-direction: row; - justify-content: center; - align-items: flex-end; - } - - .banner-info { - padding-bottom: 10.75rem; - } - .banner-hero .banner-info { - padding-bottom: 6.25rem; - } - - .banner-title { - text-align: left; - } - - .section { - padding: 0 24px 138px; - } - - .section-container { - align-items: center; - flex-direction: row; - width: var(--container-width-small); - gap: 4rem; - background: var(--background-light); - border-radius: var(--border-radius-md); - overflow: hidden; - padding: 0 1.5rem; - } - - .section-search .section-container { - align-items: center; - } - - .section-container img { - max-width: 50%; - } - - .section-search .section-container img { - order: 2; - } - } - - /* 반응형 요소에 clamp() 적용 */ - @supports (font-size: clamp(1rem, 2vw, 3rem)) { - :root { - --banner-font-size: clamp(32px, 5vw, 40px); - } - } `; export default MainPageStyle; diff --git a/src/styles/colors.ts b/src/styles/colors.ts new file mode 100644 index 00000000..e62fc4f1 --- /dev/null +++ b/src/styles/colors.ts @@ -0,0 +1,38 @@ +type GrayScale = Record< + 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900, + string +>; + +const gray: GrayScale = { + 100: "#f9fafb", + 200: "#f3f4f6", + 300: "#e5e7eb", + 400: "#9ca3af", + 500: "#6b7280", + 600: "#4b5563", + 700: "#374151", + 800: "#1f2937", + 900: "#111827", +}; + +export const COLORS = { + gray, + primary: { + DEFAULT: "#3692ff", + hover: "#1967d6", + click: "#1251aa", + }, + secondary: { + DEFAULT: gray[800], + }, + text: { + DEFAULT: gray[600], + }, + background: { + lightGray: "#fcfcfc", + blue: "#cfe5ff", + lightBlue: "#e6f2ff", + }, + error: "#f74747", + border: gray[200], +}; diff --git a/src/styles/fontSizes.ts b/src/styles/fontSizes.ts new file mode 100644 index 00000000..c046420a --- /dev/null +++ b/src/styles/fontSizes.ts @@ -0,0 +1,7 @@ +type FontScale = Record<16 | 18 | 20, string>; + +export const FONT_SIZES: FontScale = { + 16: "16px", + 18: "18px", + 20: "20px", +}; diff --git a/src/styles/variables.css b/src/styles/variables.css index 0cbe3592..e1caf194 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -31,7 +31,7 @@ --page-content-width: 75rem; --sections-padding: 24px 16px 84px; --section-margin-bottom: 16px; - --footer-padding: 32px; + --footer-padding: 32px 16px; --list-header-gap: 12px; /* form */ @@ -62,6 +62,8 @@ :root { --sections-padding: 24px 24px 56px; --section-margin-bottom: 24px; + + --footer-padding: 32px 24px; } .banner-hero .banner-info { @@ -81,6 +83,8 @@ :root { --sections-padding: 138px 24px; --section-margin-bottom: 1.5rem; + + --footer-padding: 32px 0; } } diff --git a/src/types/form.ts b/src/types/form.ts index ac96d1c1..3d9e553e 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -1,3 +1,16 @@ -export type ReqData = { +import { ChangeEvent } from "react"; + +export interface FormField { + label: string; + inputId?: string; + type?: string; + name: string; + placeholder: string; + required?: boolean; + onBlur?: (e: ChangeEvent) => void; + fieldError?: string; +} + +export interface ReqData { [key: string]: FormDataEntryValue; -}; +} diff --git a/src/utils/renderButtonText.ts b/src/utils/renderButtonText.ts new file mode 100644 index 00000000..363884e1 --- /dev/null +++ b/src/utils/renderButtonText.ts @@ -0,0 +1,26 @@ +type LoadingText = `${T}중...`; + +type ButtonState = { + isSubmitting: boolean; + defaultText: T; + loadingText: LoadingText; +}; + +const renderButtonText = (state: ButtonState) => { + const { isSubmitting, defaultText, loadingText } = state; + + return isSubmitting ? loadingText : defaultText; +}; + +export const renderButtonTextByState = ( + isSubmitting: boolean, + defaultText: T +) => { + const loadingText: LoadingText = `${defaultText}중...` as LoadingText; // 템플릿 리터럴 타입 적용 보장 + + return renderButtonText({ + isSubmitting, + defaultText, + loadingText, + }); +};