diff --git a/assets/scripts/constants/messages.js b/assets/scripts/constants/messages.js new file mode 100644 index 00000000..c60619f2 --- /dev/null +++ b/assets/scripts/constants/messages.js @@ -0,0 +1,11 @@ +import { VALIDATION_VALUES } from "./validationValues.js"; + +export const ERROR_MESSAGES = { + inputEmail: "이메일을 입력해주세요.", + invalidEmail: "잘못된 이메일 형식입니다", + inputPassword: "비밀번호를 입력해주세요.", + inputValidLengthPassword: `비밀번호를 ${VALIDATION_VALUES.passwordLength}자 이상 입력해주세요.`, + passwordNotMatch: "비밀번호가 일치하지 않습니다.", + emailAlreadyInUse: "사용 중인 이메일입니다", + inputNickname: "닉네임을 입력해주세요.", +}; diff --git a/assets/scripts/constants/names.js b/assets/scripts/constants/names.js new file mode 100644 index 00000000..d675e281 --- /dev/null +++ b/assets/scripts/constants/names.js @@ -0,0 +1,6 @@ +export const KEYS = { + email: "email", + nickname: "nickname", + password: "password", + confirmPassword: "confirmPassword", +}; diff --git a/assets/scripts/constants/userData.js b/assets/scripts/constants/userData.js new file mode 100644 index 00000000..b66f8d80 --- /dev/null +++ b/assets/scripts/constants/userData.js @@ -0,0 +1,8 @@ +export const USER_DATA = [ + { email: "codeit1@codeit.com", password: "codeit101!" }, + { email: "codeit2@codeit.com", password: "codeit202!" }, + { email: "codeit3@codeit.com", password: "codeit303!" }, + { email: "codeit4@codeit.com", password: "codeit404!" }, + { email: "codeit5@codeit.com", password: "codeit505!" }, + { email: "codeit6@codeit.com", password: "codeit606!" }, +]; diff --git a/assets/scripts/constants/validationValues.js b/assets/scripts/constants/validationValues.js new file mode 100644 index 00000000..774dfe4f --- /dev/null +++ b/assets/scripts/constants/validationValues.js @@ -0,0 +1,3 @@ +export const VALIDATION_VALUES = { + passwordLength: 8, +}; diff --git a/assets/scripts/core/sign.js b/assets/scripts/core/sign.js new file mode 100644 index 00000000..053f310b --- /dev/null +++ b/assets/scripts/core/sign.js @@ -0,0 +1,101 @@ +import $ from "../utils/query.js"; + +class Sign { + constructor() { + // 인풋 값의 유효성 상태를 저장하는 객체 + this.inputValidState = {}; + // 페이지의 인풋 DOM요소 + this.inputs = {}; + // 인풋 값의 유효성검사 메소드 + this.validateMethods = {}; + } + + init() { + this.handleInputFocusout(); + this.setFormSubmit(); + this.setCloseButton(); + this.handleToggleButton(); + } + + setState(newState) { + this.inputValidState = { + ...this.inputValidState, + ...newState, + }; + $("#submit-button").disabled = Object.values(this.inputValidState).some((state) => !state); + } + + toggleInputError(e, message = "") { + const errorMessageNode = e.target.closest("section").querySelector(".message"); + errorMessageNode.innerText = message; + if (message.length) { + e.target.classList.add("error"); + errorMessageNode.setAttribute("aria-hidden", false); + return errorMessageNode.classList.add("show"); + } + e.target.classList.remove("error"); + errorMessageNode.setAttribute("aria-hidden", true); + errorMessageNode.classList.remove("show"); + } + + handleInputFocusout() { + for (const key in this.inputs) { + this.inputs[key].addEventListener("focusout", (e) => { + const { value } = e.target; + const message = this.validateMethods[key](value); + this.toggleInputError(e, message); + this.setState({ [key]: message.length === 0 }); + }); + } + } + + setFormSubmit() { + $("form").addEventListener("submit", (e) => { + e.preventDefault(); + this.onSubmit(); + }); + } + + showModal(message) { + $("#modal-container").classList.add("show"); + $("#error-message").innerText = message; + } + + setCloseButton() { + $("#modal-close-button").addEventListener("click", () => { + $("#modal-container").classList.remove("show"); + }); + } + + getValues() { + return Object.values(this.inputs).reduce((acc, input, index) => { + return { ...acc, [Object.keys(this.inputs)[index]]: input.value }; + }, {}); + } + + onSubmit() {} + + handleSubmitSuccess() { + window.location.href = "../items/index.html"; + } + + handleSubmitFailure(message) { + this.showModal(message); + } + + togglePasswordVisibility(e) { + const { target } = e; + target.classList.toggle("fa-eye-slash"); + target.classList.toggle("fa-eye"); + const input = target.closest("div").querySelector("input"); + input.type === "text" ? (input.type = "password") : (input.type = "text"); + } + + handleToggleButton() { + document + .querySelectorAll(".eye-icon") + .forEach((icon) => icon.addEventListener("click", this.togglePasswordVisibility)); + } +} + +export default Sign; diff --git a/assets/scripts/login.js b/assets/scripts/login.js new file mode 100644 index 00000000..9269beed --- /dev/null +++ b/assets/scripts/login.js @@ -0,0 +1,43 @@ +import $ from "./utils/query.js"; +import { USER_DATA } from "./constants/userData.js"; +import { ERROR_MESSAGES } from "./constants/messages.js"; +import Sign from "./core/sign.js"; +import { validator } from "./utils/validator.js"; +import { KEYS } from "./constants/names.js"; + +class LoginForm extends Sign { + constructor() { + super(); + this.inputValidState = { + [KEYS.email]: false, + [KEYS.password]: false, + }; + this.inputs = { + [KEYS.email]: $("#email"), + [KEYS.password]: $("#password"), + }; + this.validateMethods = { + [KEYS.email]: validator.validateEmail, + [KEYS.password]: validator.validatePassword, + }; + } + + onSubmit() { + const values = this.getValues(); + const matchingAccount = USER_DATA.find((data) => data.email === values.email); + matchingAccount?.password === values.password + ? this.handleLoginSuccess() + : this.handleLoginFailure(ERROR_MESSAGES.passwordNotMatch); + } + + handleLoginSuccess() { + super.handleSubmitSuccess(); + // 이후 추가 로직 작성 + } + handleLoginFailure(message) { + super.handleSubmitFailure(message); + // 이후 추가 로직 작성 + } +} + +new LoginForm().init(); diff --git a/assets/scripts/signup.js b/assets/scripts/signup.js new file mode 100644 index 00000000..5163be38 --- /dev/null +++ b/assets/scripts/signup.js @@ -0,0 +1,52 @@ +import $ from "./utils/query.js"; +import { ERROR_MESSAGES } from "./constants/messages.js"; +import { USER_DATA } from "./constants/userData.js"; +import Sign from "./core/sign.js"; +import { validator } from "./utils/validator.js"; +import { KEYS } from "./constants/names.js"; + +class signupForm extends Sign { + constructor() { + super(); + this.inputValidState = { + [KEYS.email]: false, + [KEYS.nickname]: false, + [KEYS.password]: false, + [KEYS.confirmPassword]: false, + }; + this.inputs = { + [KEYS.email]: $("#email"), + [KEYS.nickname]: $("#nickname"), + [KEYS.password]: $("#password"), + [KEYS.confirmPassword]: $("#confirm-password"), + }; + this.validateMethods = { + [KEYS.email]: validator.validateEmail, + [KEYS.nickname]: validator.validateNickname, + [KEYS.password]: validator.validatePassword, + [KEYS.confirmPassword]: validator.validatePassword, + }; + } + + onSubmit() { + const values = this.getValues(); + const emailAlreadyInUse = USER_DATA.find((data) => data.email === values.email); + const passwordMatch = values.password === values.confirmPassword; + emailAlreadyInUse + ? this.handleSignupFailure(ERROR_MESSAGES.emailAlreadyInUse) + : !passwordMatch + ? this.handleSignupFailure(ERROR_MESSAGES.passwordNotMatch) + : this.handleSignupSuccess(); + } + + handleSignupSuccess() { + super.handleSubmitSuccess(); + // 이후 추가 로직 작성 + } + handleSignupFailure(message) { + super.handleSubmitFailure(message); + // 이후 추가 로직 작성 + } +} + +new signupForm().init(); diff --git a/assets/scripts/utils/query.js b/assets/scripts/utils/query.js new file mode 100644 index 00000000..36673740 --- /dev/null +++ b/assets/scripts/utils/query.js @@ -0,0 +1,3 @@ +const $ = (query) => document.querySelector(query); + +export default $; diff --git a/assets/scripts/utils/validator.js b/assets/scripts/utils/validator.js new file mode 100644 index 00000000..6e11a52c --- /dev/null +++ b/assets/scripts/utils/validator.js @@ -0,0 +1,27 @@ +import { VALIDATION_VALUES } from "../constants/validationValues.js"; +import { ERROR_MESSAGES } from "../constants/messages.js"; + +const emailRegex = /^[a-z0-9]+@[a-z]+\.[a-z]{2,}(\.[a-z]{2,})?$/; + +export const validator = { + validateEmail(email) { + if (!email.length) return ERROR_MESSAGES.inputEmail; + if (!emailRegex.test(email)) return ERROR_MESSAGES.invalidEmail; + return ""; + }, + validatePassword(password) { + if (!password.length) return ERROR_MESSAGES.inputPassword; + if (password.length < VALIDATION_VALUES.passwordLength) + return ERROR_MESSAGES.inputValidLengthPassword; + return ""; + }, + validateNickname(nickname) { + if (!nickname.length) return ERROR_MESSAGES.inputNickname; + return ""; + }, + + validatePasswordConfirm(password, confirmPassword) { + if (password !== confirmPassword) return ERROR_MESSAGES.passwordNotMatch; + return ""; + }, +}; diff --git a/assets/styles/home.css b/assets/styles/home.css index 6d833f73..2ee1869e 100644 --- a/assets/styles/home.css +++ b/assets/styles/home.css @@ -27,7 +27,7 @@ main { width: 100%; } -header { +nav { width: 100%; height: 7rem; display: flex; @@ -37,10 +37,10 @@ header { background-color: var(--header-bg-color); } -.header-container { +.nav-container { width: 100%; max-width: 152rem; - padding: 0 20rem; + margin: 0 20rem; display: flex; align-items: center; justify-content: space-between; @@ -51,7 +51,7 @@ header { } .login-button { - width: 12.8rem; + min-width: 12.8rem; height: 4.8rem; border: none; border-radius: 0.8rem; @@ -189,19 +189,20 @@ header { word-break: keep-all; } -.main-page-text.first { - max-width: 28rem; +.main-page-text { + display: flex; + flex-direction: column; } -.main-page-text.second { - max-width: 30rem; +.main-page:nth-of-type(1) .main-page-text { + max-width: 28rem; } -.main-page-text.third { - max-width: 33rem; +.main-page:nth-of-type(2) .main-page-text { + max-width: 30rem; } -.main-title.third { +.main-page:nth-of-type(3) .main-title { max-width: 30rem; } @@ -213,9 +214,12 @@ header { word-break: keep-all; } -.align-right { - display: flex; - flex-direction: column; +.main-page-text.align-left { + align-items: start; + text-align: left; +} + +.main-page-text.align-right { align-items: end; text-align: right; } @@ -232,7 +236,7 @@ footer { .footer-container { width: 100%; max-width: 152rem; - padding: 0 20rem; + margin: 0 20rem; font-size: 1.6rem; font-weight: 400; margin-top: 3.2rem; @@ -271,3 +275,182 @@ footer { width: 1.8rem; height: 1.8rem; } + +@media (max-width: 1199px) { + .nav-container { + margin: 0 2.4rem; + } + + .banner { + height: 77.1rem; + margin-bottom: 2.4rem; + } + + .banner.bottom { + height: 92.7rem; + margin: 0; + } + + .banner-content { + flex-direction: column; + align-items: center; + justify-content: space-between; + max-width: 100%; + width: 100%; + } + .banner-text-container { + font-size: 4rem; + max-width: 100%; + align-items: center; + margin-bottom: 21rem; + } + + .banner-text { + text-align: center; + } + + .image-container, + .image-container.bottom { + width: auto; + } + + .main-page-section { + gap: 5.2rem; + } + + .main-page { + height: auto; + align-items: start; + } + + .main-page-image { + width: 100%; + } + + .main-page-text { + margin: 0; + } + + .main-page:nth-of-type(1) .main-page-text { + max-width: 50rem; + } + + .main-page:nth-of-type(2) .main-page-text, + .main-page:nth-of-type(3) .main-page-text { + max-width: 60rem; + } + + .main-title { + font-size: 3.2rem; + line-height: 4.2rem; + margin-bottom: 2.4rem; + } + + .description { + font-size: 1.8rem; + line-height: 2.6rem; + max-width: 30rem; + } + + .main-page:nth-of-type(3) .description { + max-width: 27rem; + } + + .sub-title { + margin-bottom: 1.6rem; + } + + .main-content-container { + flex-direction: column; + width: 69.6rem; + max-height: 100%; + max-width: 100%; + gap: 2.4rem; + align-items: start; + } + + .main-content-container.align-right { + flex-direction: column-reverse; + align-items: end; + } + + .main-page:nth-of-type(3) .main-title { + max-width: 60rem; + } + + .banner.bottom { + margin-top: 5.6rem; + } + + .footer-container { + margin: 3.2rem 2.4rem; + padding: 0; + } +} + +@media (max-width: 743px) { + .nav-container { + margin: 0 1.6rem; + } + + .banner, + .banner.bottom { + width: 100%; + height: 144vw; + } + + .banner-text { + font-size: 3.2rem; + line-height: 4.48rem; + } + + .banner-button { + padding: 1.2rem 7.1rem; + width: 24rem; + font-size: 1.6rem; + } + + .banner-text-container { + margin-bottom: 13.2rem; + width: 24rem; + } + + .sub-title { + font-size: 1.6rem; + line-height: 2.6rem; + margin-bottom: 0.8rem; + } + + .main-title { + font-size: 2.4rem; + line-height: 3.2rem; + margin-bottom: 1.6rem; + } + + .description { + font-size: 1.6rem; + line-height: 1.92rem; + } + + .main-page:nth-of-type(1) .description { + max-width: 18.3rem; + } + .main-page:nth-of-type(2) .description { + max-width: 22rem; + } + .main-page:nth-of-type(3) .description { + max-width: 24.7rem; + } + + .main-page { + width: calc(100% - 3.2rem); + } + + .footer-container { + flex-wrap: wrap; + } + + .copyright { + margin-bottom: 2.4rem; + } +} diff --git a/assets/styles/sign.css b/assets/styles/sign.css index 18774811..f17d76cd 100644 --- a/assets/styles/sign.css +++ b/assets/styles/sign.css @@ -1,12 +1,13 @@ :root { --main-background-color: #fcfcfc; --main-ivory-color: #f3f4f6; - --input-focus-border-color: #3692ff; + --active-blue-color: #3692ff; --main-grey-color: #9ca3af; --main-font-color: #1f2937; --social-login-bg-color: #e6f2ff; --sign-up-link-font-color: #3182f6; --password-visible-button-color: #4b5563; + --warning-invalid-color: #f74747; } html { @@ -52,6 +53,12 @@ header img { height: auto; } +form > section { + margin-bottom: 2.4rem; + display: flex; + flex-direction: column; +} + label { margin-bottom: 1.6rem; font-size: 1.8rem; @@ -63,7 +70,6 @@ button { width: 100%; height: 5.6rem; border: none; - margin-bottom: 2.4rem; } input { @@ -73,8 +79,12 @@ input { font-weight: 400; } +input.error { + border: solid 1px var(--warning-invalid-color); +} + input:focus { - outline: 1px solid var(--input-focus-border-color); + outline: 1px solid var(--active-blue-color); } ::placeholder { @@ -83,11 +93,17 @@ input:focus { button { border-radius: 4rem; - background-color: var(--main-grey-color); + background-color: var(--active-blue-color); color: var(--main-ivory-color); font-size: 2rem; line-height: 3.2rem; font-weight: 600; + margin-bottom: 2.4rem; +} + +button:disabled { + background-color: var(--main-grey-color); + cursor: not-allowed; } .social-login { @@ -144,3 +160,106 @@ button { color: var(--password-visible-button-color); cursor: pointer; } + +.message { + display: none; +} + +.message.show { + display: block; + font-size: 1.5rem; + line-height: 1.8rem; + font-weight: 600; + color: var(--warning-invalid-color); + padding-left: 16px; + padding-top: 8px; +} + +#modal-container { + display: none; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + z-index: 50; +} + +#modal-container.show { + display: block; +} + +#modal-background { + width: 100%; + height: 100%; + background-color: #000000; + opacity: 0.7; + z-index: 51; +} + +#modal { + position: absolute; + top: calc(50% - 12.5rem); + left: calc(50% - 27rem); + width: 54rem; + height: 25rem; + border-radius: 0.8rem; + background-color: #ffffff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +#error-message { + position: absolute; + top: 10.8rem; + font-size: 1.6rem; + font-weight: 500; + line-height: 1.92rem; +} + +#modal-close-button { + position: absolute; + width: 12rem; + height: 4.8rem; + border-radius: 0.8rem; + right: 2.8rem; + bottom: 2.8rem; + margin-bottom: 0; + font-size: 1.6rem; + line-height: 1.92rem; + font-weight: 600; +} + +@media (max-width: 743px) { + main { + width: calc(100% - 3.2rem); + max-width: 40rem; + margin: 0 1.6rem; + margin-top: 2.4rem; + } + + form > section { + margin-bottom: 1.6rem; + } + + header img { + width: 19.8rem; + } + + #modal { + width: 32.7rem; + height: 22rem; + top: calc(50% - 11rem); + left: calc(50% - 16.35rem); + } + + #modal-close-button { + left: calc(50% - 6rem); + bottom: 2.3rem; + } + #error-message { + top: 8.1rem; + } +} diff --git a/docs/sprint3.md b/docs/sprint3.md new file mode 100644 index 00000000..e22785dc --- /dev/null +++ b/docs/sprint3.md @@ -0,0 +1,74 @@ +# 스프린트 미션 3 요구사항 + +## 로그인, 회원가입 페이지 공통 + +### 기본 요구사항 + +- [x] 로그인 및 회원가입 페이지의 이메일, 비밀번호, 비밀번호 확인 input에 필요한 유효성 검증 함수를 만들고 적용해 주세요. +- [x] 이메일 input에서 focus out 할 때, 값이 없을 경우 input에 빨강색 테두리와 아래에 “이메일을 입력해주세요.” 빨강색 에러 메세지를 보입니다. +- [x] 이메일 input에서 focus out 할 때, 이메일 형식에 맞지 않는 경우 input에 빨강색 테두리와 아래에 “잘못된 이메일 형식입니다” 빨강색 에러 메세지를 보입니다. +- [x] 비밀번호 input에서 focus out 할 때, 값이 없을 경우 아래에 “비밀번호를 입력해주세요.” 에러 메세지를 보입니다 +- [x] 비밀번호 input에서 focus out 할 때, 값이 8자 미만일 경우 아래에 “비밀번호를 8자 이상 입력해주세요.” 에러 메세지를 보입니다. +- [x] input 에 빈 값이 있거나 에러 메세지가 있으면 ‘로그인’ 버튼은 비활성화 됩니다. +- [x] Input 에 유효한 값을 입력하면 ‘로그인' 버튼이 활성화 됩니다. +- [x] 활성화된 ‘로그인’ 버튼을 누르면 “/items” 로 이동합니다 + +### 심화 요구사항 + +- [x] 브라우저에 현재 보이는 화면의 영역(viewport) 너비를 기준으로 분기되는 반응형 디자인을 적용합니다. (참고: https://www.codeit.kr/topics/responsive-web-publishing) + + - PC: 1200px 이상 + - Tablet: 744px 이상 ~ 1199px 이하 + - Mobile: 375px 이상 ~ 743px 이하 + - 375px 미만 사이즈의 디자인은 고려하지 않습니다. + +- [x] Tablet 사이즈에서 내부 디자인은 PC사이즈와 동일합니다. +- [x] Mobile 사이즈에서 좌우 여백 16px 제외하고 내부 요소들이 너비를 모두 차지합니다. +- [x] Mobile 사이즈에서 내부 요소들의 너비는 기기의 너비가 커지는 만큼 커지지만 400px을 넘지 않습니다. +- [x] 비밀번호 및 비밀번호 확인 입력란에 눈 모양 아이콘 클릭 시 비밀번호 표시/숨기기 토글이 가능합니다. 기본 상태는 비밀번호 숨김으로 설정합니다. + +## 로그인 페이지 + +### 기본 요구사항 + +- [x] 이메일과 비밀번호를 입력하고 로그인 버튼을 누른 후, 다음 조건을 참조하여 로그인 성공 여부를 alert 메시지로 출력합니다. +- [x] 만약 입력한 이메일이 데이터베이스(USER_DATA)에 없거나, 이메일은 일치하지만 비밀번호가 틀린 경우, '비밀번호가 일치하지 않습니다.'라는 메시지를 alert로 표시합니다 +- [x] 만약 입력한 이메일이 데이터베이스에 존재하고, 비밀번호도 일치할 경우, “/items”로 이동합니다. + +### 심화 요구사항 + +- [x] 오류 메시지 모달을 구현합니다. 모달 내 내용은 alert 메시지와 동일합니다. + +## 회원가입 페이지 + +### 기본 요구사항 + +- [x] 회원가입을 위해 이메일, 닉네임, 비밀번호, 비밀번호 확인을 입력한 뒤, 회원가입 버튼을 클릭하세요. 그 후에는 다음 조건에 따라 회원가입 가능 여부를 alert로 알려주세요. +- [x] 입력한 이메일이 이미 데이터베이스(USER_DATA)에 존재하는 경우, '사용 중인 이메일입니다'라는 메시지를 alert로 표시합니다. +- [x] 입력한 이메일이 데이터베이스(USER_DATA)에 없는 경우, 회원가입이 성공적으로 처리되었으므로 로그인 페이지(”/login”)로 이동합니다. + +### 심화 요구사항 + +- [x] 오류 메시지 모달을 구현합니다. 모달 내 내용은 alert 메시지와 동일합니다. + +## 랜딩 페이지 + +### 심화 요구사항 + +- [x] 브라우저에 현재 보이는 화면의 영역(viewport) 너비를 기준으로 분기되는 반응형 디자인을 적용합니다. (참고: https://www.codeit.kr/topics/responsive-web-publishing) + + - PC: 1200px 이상 + - Tablet: 744px 이상 ~ 1199px 이하 + - Mobile: 375px 이상 ~ 743px 이하 + - 375px 미만 사이즈의 디자인은 고려하지 않습니다. + +- [x] PC, Tablet 사이즈의 이미지 크기는 고정값을 사용합니다. +- [x] Mobile 사이즈의 이미지는 좌우 여백 32px을 제외하고 이미지 영역이 꽉 차게 구현합니다. (이때 가로가 커지는 비율에 맞춰 세로도 커져야 합니다.) +- [x] Tablet 사이즈로 작아질 때 최소 좌우 여백이 “판다마켓” 로고의 왼쪽에 여백 24px, “로그인” 버튼 오른쪽 여백 24px을 유지할 수 있도록 “판다마켓” 로고와 “로그인" 버튼의 간격이 가까워집니다. +- [x] Mobile 사이즈로 작아질 때 최소 좌우 여백이 “판다마켓” 로고의 왼쪽에 여백 16px, “로그인” 버튼 오른쪽 여백 16px을 유지할 수 있도록 “판다마켓” 로고와 “로그인" 버튼의 간격이 가까워집니다. +- [x] Mobile 사이즈 너비가 커지면, “Privacy Policy”, “FAQ”, “codeit-2023”이 있는 영역과 SNS 아이콘들이 있는 영역의 사이 간격이 커집니다. + +- [x] 페이스북, 카카오톡, 디스코드, 트위터 등 SNS에서 판다마켓 랜딩 페이지(“/”) 공유 시 미리보기를 볼 수 있도록 랜딩 페이지 메타 태그를 설정합니다. +- [x] 미리보기에서 제목은 “판다마켓”, 설명은 “일상에서 모든 물건을 거래해보세요”로 설정합니다. +- [x] 주소와 이미지는 자유롭게 설정하세요. +- [x] 로그인, 회원가입 페이지에 공통으로 사용하는 로직이 있다면, 반복하지 않고 공통된 로직을 모듈로 분리해 사용해 주세요. diff --git a/index.html b/index.html index 063a9bd7..e8dfc619 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,21 @@ + + + + + + + + + Panda Market @@ -25,8 +40,8 @@
-
-
+
+