From 829755e7d34904c0949f7afad74046fd0b9a4d17 Mon Sep 17 00:00:00 2001 From: whai2 Date: Tue, 9 Apr 2024 16:37:03 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs:=2015,=2016=EC=A3=BC=EC=B0=A8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=82=AC=ED=95=AD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 63 +++++++++++++++++++++++++------------------------- docs/README.md | 38 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 31 deletions(-) create mode 100644 docs/README.md diff --git a/README.md b/README.md index 39a34a4535..35e6961a6b 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,39 @@ -# 13주차 +# 15 주차 ## 기본 구현 사항 -- [x] TypeScript를 활용해 프로젝트의 필요한 곳에 타입을 명시해 주세요. -- [x] 검색어를 입력하면 현재 폴더에 있는 링크들 중 “url”, “title”, “description”에 검색어가 포함된 링크들만 필터해서 보이게 해주세요. -- [x] x 버튼을 클릭하면 입력값이 없던 ui 상태로 돌아갑니다. +- [ ] 로그인/회원가입시 성공 응답으로 받은 accessToken을 로컬 스토리지에 저장합니다. +- [ ] 로그인/회원가입 페이지에 접근시 로컬 스토리지에 accessToken이 있는 경우 “/folder” 페이지로 이동합니다. +- [ ] “회원 가입하기”를 클릭하면 ‘/signup’ 페이지로 이동합니다. +- [ ] 이메일 input에 placeholder는 “이메일을 입력해 주세요.”비밀번호 input에 placeholder는 “비밀번호를 입력해 주세요.”로 설정해 주세요. +- [ ] 이메일 input에서 focus out 할 때, 값이 없을 경우 아래에 “이메일을 입력해 주세요.” 에러 메세지를 보입니다. +- [ ] 이메일 input에서 focus out 할 때, 이메일 형식에 맞지 않는 값이 있는 경우 아래에 “올바른 이메일 주소가 아닙니다.” 에러 메세지를 보입니다. +- [ ] 비밀번호 input에서 focus out 할 때, 값이 없을 경우 아래에 “비밀번호를 입력해 주세요.” 에러 메세지를 보입니다. +- [ ] 로그인 실패하는 경우, 이메일 input 아래에 “이메일을 확인해 주세요.”, 비밀번호 input 아래에 “비밀번호를 확인해 주세요.” 에러 메세지를 보입니다. +- [ ] 로그인 버튼 클릭 또는 Enter키 입력으로 로그인 실행돼야 합니다. +- [ ] https://bootcamp-api.codeit.kr/docs 에 명세된 “/api/sign-in”으로 { “email”: “test@codeit.com”, “password”: “sprint101” } POST 요청해서 성공 응답을 받으면 “/folder”로 이동합니다. +- [ ] 소셜 로그인에 구글 아이콘 클릭시 ‘https://www.google.com’카카오 아이콘 클릭시 ‘https://www.kakaocorp.com/page’로 이동하게 해주세요. +- [ ] 눈 모양 아이콘 클릭시 비밀번호의 문자열이 보이기도 하고, 가려지기도 합니다. +- [ ] 비밀번호의 문자열이 가려질 때는 눈 모양 아이콘에는 사선이 그어져있고, 비밀번호의 문자열이 보일 때는 사선이 없는 눈 모양 아이콘이 보이도록 합니다. ## 심화 구현 사항 -- [ ] 상단에 있던 링크 추가하기 영역이 가려져 보이지 않을 때 최하단에 링크 추가하기 영역을 고정하도록 만들어 주세요. -- [ ] 푸터가 시작되는 지점에서는 최하단에 고정된 링크 추가하기 영역이 보이지 않게 해주세요.(IntersectionObserver를 활용해 보세요.) +- [ ] 로그인, 회원가입 기능에 react-hook-form을 활용해 주세요. -# 14주차 +# 16 주차 ## 기본 구현 사항 -- [x] 기존 React 프로젝트에서 진행했던 작업물을 Next.js 프로젝트에 맞게 변경 및 이전해 주세요. -- [x] next/link의 Link를 활용해 Linkbrary 아이콘을 클릭하면 ‘/’ 페이지로 이동하게 해주세요. +- [ ] 링크 공유 페이지의 url path를 ‘/shared’에서 ‘/shared/{folderId}’로 변경해 주세요. +- [ ] 폴더의 정보는 ‘/api/folders/{folderId}’, 폴더 소유자의 정보는 ‘/api/users/{userId}’를 활용해 주세요. +- [ ] 링크 공유 페이지에서 폴더의 링크 데이터는 ‘/api/users/{userId}/links?folderId={folderId}’를 사용해 주세요. +- [ ] 폴더 페이지에서 유저가 access token이 없는 경우 ‘/signin’페이지로 이동하게 해주세요. +- [ ] 테스트 유저는 id: “codeit@codeit.com”, pw: “sprint101” 를 활용해 보세요. +- [ ] 폴더 페이지의 url path가 ‘/folder’일 경우 폴더 목록에서 “전체” 가 선택되어 있고, ‘/folder/{folderId}’일 경우 폴더 목록에서 {folderId} 에 해당하는 폴더가 선택되어 있고 폴더에 있는 링크들을 볼 수 있게 해주세요. +- [ ] 폴더 페이지에서 현재 유저의 폴더 목록 데이터를 받아올 때 ‘/api/folders’를 활용해 주세요. +- [ ] 폴더 페이지에서 전체 링크 데이터를 받아올 때 ‘/api/links’, 특정 폴더의 링크를 받아올 때 ‘/api/links?folderId={folderId}’를 활용해 주세요. +- [ ] 유효한 access token이 있는 경우 ‘/api/users’로 현재 로그인한 유저 정보를 받아 상단 네비게이션 유저 프로필을 보이게 해주세요. -# css 적용 -- [x] css 적용 방식을 선택한다. (css in js vs tailwind) - -[x] 테일윈드의 경우, 컴포넌트 단위부터 조금씩 바꾼다. -- [x] css in js를 사용한 경우, 그 이유를 분명히 한다. -- next는 css in js를 선호하지 않는다. 이를 고찰한다. - -# app Router 적용 -- [x] 앱 라우터를 적용한다. - - [x] pages를 제거한다. - - [x] pages의 _app.tsx, _documents.tsx 내용은 app/layout.tsx로 이전한다. - - [x] pages의 index.tsx는 app/page.tsx에 이전한다. - - [x] styles를 제거한다. -- [x] 만약, pr 머지 충돌이 일어날 경우, 원격 저장소의 내용을 위와 같은 절차로 제거한다. - -# 서버 컴포넌트 사용 -- [x] 데이터를 호출해야 하는 경우, 서버 컴포넌트로 분류한다. - - [x] 서버 컴포넌트는 클라이언트 컴포넌트에서 호출(import)이 불가능하다. -> {children}으로 호출한다. 이는, 비동기 렌더링을 적용하기 위함이다. - - [x] useState, useEffect와 같은 생명 주기, 상태를 가질 수 없다. 즉, 1번 렌더링 된다. -- [x] 클라이언트 컴포넌트와 서버 컴포넌트를 엄격히 분리한다. +## 심화 구현 사항 +- [ ] 리퀘스트 헤더에 인증 토큰을 첨부할 때 axios interceptors 또는 이와 유사한 기능을 활용해 주세요. -# 클라이언트 컴포넌트 사용 -- [x] 클라이언트 컴포넌트 내부에서는 데이터를 호출하지 않는다. -- [x] 컴포넌트 포함 관계를 통해, 서버에서 렌더링이 되고 있는지, 브라우저에서 렌더링이 되고 있는지를 분명히 구별한다. 이를 통해, 웹 사이트를 최적화한다. \ No newline at end of file +## 추가 구현 사항 +- [ ] swr 혹은 react-Query를 이용하기. +- [ ] server actions을 통해, 서버에서 실행하는 로직 분리하기. +- [ ] 유효성 검사, 중복 검사의 경우, swr 혹은 react-Query의 뮤테이션을 이용하기. +- [ ] search 부드럽게 적용하기. (spa로 이용) \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..39a34a4535 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# 13주차 +## 기본 구현 사항 +- [x] TypeScript를 활용해 프로젝트의 필요한 곳에 타입을 명시해 주세요. +- [x] 검색어를 입력하면 현재 폴더에 있는 링크들 중 “url”, “title”, “description”에 검색어가 포함된 링크들만 필터해서 보이게 해주세요. +- [x] x 버튼을 클릭하면 입력값이 없던 ui 상태로 돌아갑니다. + +## 심화 구현 사항 +- [ ] 상단에 있던 링크 추가하기 영역이 가려져 보이지 않을 때 최하단에 링크 추가하기 영역을 고정하도록 만들어 주세요. +- [ ] 푸터가 시작되는 지점에서는 최하단에 고정된 링크 추가하기 영역이 보이지 않게 해주세요.(IntersectionObserver를 활용해 보세요.) + +# 14주차 +## 기본 구현 사항 +- [x] 기존 React 프로젝트에서 진행했던 작업물을 Next.js 프로젝트에 맞게 변경 및 이전해 주세요. +- [x] next/link의 Link를 활용해 Linkbrary 아이콘을 클릭하면 ‘/’ 페이지로 이동하게 해주세요. + +# css 적용 +- [x] css 적용 방식을 선택한다. (css in js vs tailwind) + -[x] 테일윈드의 경우, 컴포넌트 단위부터 조금씩 바꾼다. +- [x] css in js를 사용한 경우, 그 이유를 분명히 한다. +- next는 css in js를 선호하지 않는다. 이를 고찰한다. + +# app Router 적용 +- [x] 앱 라우터를 적용한다. + - [x] pages를 제거한다. + - [x] pages의 _app.tsx, _documents.tsx 내용은 app/layout.tsx로 이전한다. + - [x] pages의 index.tsx는 app/page.tsx에 이전한다. + - [x] styles를 제거한다. +- [x] 만약, pr 머지 충돌이 일어날 경우, 원격 저장소의 내용을 위와 같은 절차로 제거한다. + +# 서버 컴포넌트 사용 +- [x] 데이터를 호출해야 하는 경우, 서버 컴포넌트로 분류한다. + - [x] 서버 컴포넌트는 클라이언트 컴포넌트에서 호출(import)이 불가능하다. -> {children}으로 호출한다. 이는, 비동기 렌더링을 적용하기 위함이다. + - [x] useState, useEffect와 같은 생명 주기, 상태를 가질 수 없다. 즉, 1번 렌더링 된다. +- [x] 클라이언트 컴포넌트와 서버 컴포넌트를 엄격히 분리한다. + +# 클라이언트 컴포넌트 사용 +- [x] 클라이언트 컴포넌트 내부에서는 데이터를 호출하지 않는다. +- [x] 컴포넌트 포함 관계를 통해, 서버에서 렌더링이 되고 있는지, 브라우저에서 렌더링이 되고 있는지를 분명히 구별한다. 이를 통해, 웹 사이트를 최적화한다. \ No newline at end of file From 24ce21af3e31e60d47c4a844a978665d80f77a1d Mon Sep 17 00:00:00 2001 From: whai2 Date: Tue, 9 Apr 2024 22:28:20 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20signin=20=EA=B5=AC=ED=98=84,=20reac?= =?UTF-8?q?t-hook-form=EC=9D=84=20=EC=9D=B4=EC=9A=A9=ED=95=B4,=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=AC=B8=EA=B5=AC=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +-- app/(sign)/layout.module.scss | 26 ++++++++ app/(sign)/layout.tsx | 16 +++++ app/(sign)/signin/page.tsx | 18 +++++ app/(sign)/signup/page.tsx | 14 ++++ app/shared/page.tsx | 9 +++ components/sign/SignHeader.module.scss | 30 +++++++++ components/sign/SignHeader.tsx | 39 +++++++++++ components/sign/SignInForm.module.scss | 23 +++++++ components/sign/SignInForm.tsx | 66 +++++++++++++++++++ components/sign/SignUpForm.tsx | 0 components/sign/form/Input.module.scss | 38 +++++++++++ components/sign/form/Input.tsx | 39 +++++++++++ .../sign/form/PasswordInput.module.scss | 11 ++++ components/sign/form/PasswordInput.tsx | 61 +++++++++++++++++ lib/constant.ts | 21 ++++++ lib/searchData.ts | 2 +- package-lock.json | 64 ++++++++++++++++++ package.json | 4 ++ public/eye-off.svg | 4 ++ public/eye-on.svg | 4 ++ styles/colors.scss | 16 +++++ styles/global.scss | 3 + styles/mixin.scss | 24 +++++++ styles/variables.scss | 4 ++ 25 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 app/(sign)/layout.module.scss create mode 100644 app/(sign)/layout.tsx create mode 100644 app/(sign)/signin/page.tsx create mode 100644 app/(sign)/signup/page.tsx create mode 100644 components/sign/SignHeader.module.scss create mode 100644 components/sign/SignHeader.tsx create mode 100644 components/sign/SignInForm.module.scss create mode 100644 components/sign/SignInForm.tsx create mode 100644 components/sign/SignUpForm.tsx create mode 100644 components/sign/form/Input.module.scss create mode 100644 components/sign/form/Input.tsx create mode 100644 components/sign/form/PasswordInput.module.scss create mode 100644 components/sign/form/PasswordInput.tsx create mode 100644 lib/constant.ts create mode 100644 public/eye-off.svg create mode 100644 public/eye-on.svg create mode 100644 styles/colors.scss create mode 100644 styles/global.scss create mode 100644 styles/mixin.scss create mode 100644 styles/variables.scss diff --git a/README.md b/README.md index 35e6961a6b..341cd22f12 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ - [ ] 로그인/회원가입시 성공 응답으로 받은 accessToken을 로컬 스토리지에 저장합니다. - [ ] 로그인/회원가입 페이지에 접근시 로컬 스토리지에 accessToken이 있는 경우 “/folder” 페이지로 이동합니다. - [ ] “회원 가입하기”를 클릭하면 ‘/signup’ 페이지로 이동합니다. -- [ ] 이메일 input에 placeholder는 “이메일을 입력해 주세요.”비밀번호 input에 placeholder는 “비밀번호를 입력해 주세요.”로 설정해 주세요. -- [ ] 이메일 input에서 focus out 할 때, 값이 없을 경우 아래에 “이메일을 입력해 주세요.” 에러 메세지를 보입니다. -- [ ] 이메일 input에서 focus out 할 때, 이메일 형식에 맞지 않는 값이 있는 경우 아래에 “올바른 이메일 주소가 아닙니다.” 에러 메세지를 보입니다. -- [ ] 비밀번호 input에서 focus out 할 때, 값이 없을 경우 아래에 “비밀번호를 입력해 주세요.” 에러 메세지를 보입니다. +- [x] 이메일 input에 placeholder는 “이메일을 입력해 주세요.”비밀번호 input에 placeholder는 “비밀번호를 입력해 주세요.”로 설정해 주세요. +- [x] 이메일 input에서 focus out 할 때, 값이 없을 경우 아래에 “이메일을 입력해 주세요.” 에러 메세지를 보입니다. +- [x] 이메일 input에서 focus out 할 때, 이메일 형식에 맞지 않는 값이 있는 경우 아래에 “올바른 이메일 주소가 아닙니다.” 에러 메세지를 보입니다. +- [x] 비밀번호 input에서 focus out 할 때, 값이 없을 경우 아래에 “비밀번호를 입력해 주세요.” 에러 메세지를 보입니다. - [ ] 로그인 실패하는 경우, 이메일 input 아래에 “이메일을 확인해 주세요.”, 비밀번호 input 아래에 “비밀번호를 확인해 주세요.” 에러 메세지를 보입니다. - [ ] 로그인 버튼 클릭 또는 Enter키 입력으로 로그인 실행돼야 합니다. - [ ] https://bootcamp-api.codeit.kr/docs 에 명세된 “/api/sign-in”으로 { “email”: “test@codeit.com”, “password”: “sprint101” } POST 요청해서 성공 응답을 받으면 “/folder”로 이동합니다. @@ -15,7 +15,7 @@ - [ ] 비밀번호의 문자열이 가려질 때는 눈 모양 아이콘에는 사선이 그어져있고, 비밀번호의 문자열이 보일 때는 사선이 없는 눈 모양 아이콘이 보이도록 합니다. ## 심화 구현 사항 -- [ ] 로그인, 회원가입 기능에 react-hook-form을 활용해 주세요. +- [x] 로그인, 회원가입 기능에 react-hook-form을 활용해 주세요. # 16 주차 ## 기본 구현 사항 diff --git a/app/(sign)/layout.module.scss b/app/(sign)/layout.module.scss new file mode 100644 index 0000000000..babd61725a --- /dev/null +++ b/app/(sign)/layout.module.scss @@ -0,0 +1,26 @@ +@import "@/styles/global.scss"; + +.container { + display: flex; + justify-content: center; + min-height: 100vh; + padding: calc(120 / 844 * 100vh) 3.2rem 5rem; + background-color: $color-light-blue; + + @include tablet { + padding-top: calc(200 / 982 * 100vh); + } + + @include desktop { + padding-top: calc(238 / 982 * 100vh); + } +} + +.items { + display: flex; + flex-direction: column; + align-items: center; + row-gap: 3rem; + width: 100%; + max-width: 40rem; +} diff --git a/app/(sign)/layout.tsx b/app/(sign)/layout.tsx new file mode 100644 index 0000000000..f5ab7015de --- /dev/null +++ b/app/(sign)/layout.tsx @@ -0,0 +1,16 @@ +import classNames from "classnames/bind"; +import styles from "./layout.module.scss"; + +const cx = classNames.bind(styles); + +const signLayout = ({ children }: { children: React.ReactNode }) => { + return ( +
+
+ {children} +
+
+ ); +}; + +export default signLayout; diff --git a/app/(sign)/signin/page.tsx b/app/(sign)/signin/page.tsx new file mode 100644 index 0000000000..c91a027572 --- /dev/null +++ b/app/(sign)/signin/page.tsx @@ -0,0 +1,18 @@ +import { ROUTE } from "@/lib/constant" + +import SignHeader from '@/components/sign/SignHeader' +import SignInForm from "@/components/sign/SignInForm" + +const page = () => { + return ( + <> + + + + ) +} + +export default page \ No newline at end of file diff --git a/app/(sign)/signup/page.tsx b/app/(sign)/signup/page.tsx new file mode 100644 index 0000000000..646ebfad0d --- /dev/null +++ b/app/(sign)/signup/page.tsx @@ -0,0 +1,14 @@ +import { ROUTE } from "@/lib/constant" + +import SignHeader from '@/components/sign/SignHeader' + +const page = () => { + return ( + + ) +} + +export default page \ No newline at end of file diff --git a/app/shared/page.tsx b/app/shared/page.tsx index e69de29bb2..983ebb85bc 100644 --- a/app/shared/page.tsx +++ b/app/shared/page.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +const page = () => { + return ( +
page
+ ) +} + +export default page \ No newline at end of file diff --git a/components/sign/SignHeader.module.scss b/components/sign/SignHeader.module.scss new file mode 100644 index 0000000000..158b161d40 --- /dev/null +++ b/components/sign/SignHeader.module.scss @@ -0,0 +1,30 @@ +@import "@/styles/global.scss"; + +.container { + display: flex; + flex-direction: column; + align-items: center; + row-gap: 1.6rem; +} + +.logo { + width: fit-content; + height: 3.8rem; +} + +.message-box { + display: flex; + column-gap: 0.8rem; + font-size: 1.6rem; +} + +.message { + line-height: 150%; +} + +.link { + height: fit-content; + font-weight: 600; + color: $color-primary; + border-bottom: solid 0.1rem $color-primary; +} diff --git a/components/sign/SignHeader.tsx b/components/sign/SignHeader.tsx new file mode 100644 index 0000000000..5a0c2ff348 --- /dev/null +++ b/components/sign/SignHeader.tsx @@ -0,0 +1,39 @@ +import Image from "next/image"; +import Link from "next/link"; +import { Url } from "next/dist/shared/lib/router/router"; + +import LinkbraryIcon from "@/public/logo.svg"; +import { ROUTE } from "@/lib/constant" + +import classNames from "classnames/bind"; +import styles from "./SignHeader.module.scss"; + +const cx = classNames.bind(styles); + +type SignHeaderProps = { + message: string; + link: { + href: Url; + text: string; + }; +}; + +const SignHeader = ({ message, link }: SignHeaderProps) => { + const { href, text } = link; + + return ( +
+ + 로고 + +

+ {message} + + {text} + +

+
+ ) +} + +export default SignHeader \ No newline at end of file diff --git a/components/sign/SignInForm.module.scss b/components/sign/SignInForm.module.scss new file mode 100644 index 0000000000..9832427d2c --- /dev/null +++ b/components/sign/SignInForm.module.scss @@ -0,0 +1,23 @@ +.form { + display: flex; + flex-direction: column; + row-gap: 2.4rem; + width: 100%; +} + +.label { + font-size: 1.4rem; +} + +.input-box { + display: flex; + flex-direction: column; + row-gap: 1.2rem; +} + +.button { + margin-top: 0.6rem; + height: 5.4rem; + font-size: 1.8rem; + font-weight: 600; +} diff --git a/components/sign/SignInForm.tsx b/components/sign/SignInForm.tsx new file mode 100644 index 0000000000..0a327339c9 --- /dev/null +++ b/components/sign/SignInForm.tsx @@ -0,0 +1,66 @@ +'use client' + +import { Controller, useForm } from "react-hook-form"; + +import { PLACEHOLDER, ERROR_MESSAGE } from "@/lib/constant"; + +import Input from "./form/Input"; +import PasswordInput from "./form/PasswordInput"; + +import styles from "./SignInForm.module.scss"; +import classNames from "classnames/bind"; + +const cx = classNames.bind(styles); + +const SignInForm = () => { + const { control, handleSubmit, watch, setError } = useForm({ + defaultValues: { email: "", password: "" }, + mode: "onBlur", + }); + + return ( +
+
+ + ( + + )} + /> +
+
+ + ( + + )} + /> +
+ +
+ ); +}; + +export default SignInForm \ No newline at end of file diff --git a/components/sign/SignUpForm.tsx b/components/sign/SignUpForm.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/components/sign/form/Input.module.scss b/components/sign/form/Input.module.scss new file mode 100644 index 0000000000..e97f2b823c --- /dev/null +++ b/components/sign/form/Input.module.scss @@ -0,0 +1,38 @@ +@import "@/styles/global.scss"; + +.container { + width: 100%; + display: flex; + flex-direction: column; + row-gap: 0.6rem; +} + +.input { + width: 100%; + border: 0.1rem solid $color-gray20; + border-radius: 0.8rem; + font-size: 1.6rem; + color: $color-gray100; + padding: 1.8rem 1.5rem; + transition: border-color 0.2s ease-in-out; + + &::placeholder { + color: $color-gray60; + } + + &:focus { + border-color: $color-primary; + } + + &.error { + border-color: $color-red; + } +} + +.helper-text { + font-size: 1.4rem; + + &.error { + color: $color-red; + } +} diff --git a/components/sign/form/Input.tsx b/components/sign/form/Input.tsx new file mode 100644 index 0000000000..52d925111b --- /dev/null +++ b/components/sign/form/Input.tsx @@ -0,0 +1,39 @@ +import { ChangeEventHandler, FocusEventHandler, HTMLInputTypeAttribute, forwardRef } from "react"; + +import styles from "./Input.module.scss"; +import classNames from "classnames/bind"; + +const cx = classNames.bind(styles); + +export type InputProps = { + value: string | number; + placeholder?: string; + type?: HTMLInputTypeAttribute; + hasError?: boolean; + helperText?: string; + onChange: ChangeEventHandler; + onBlur?: FocusEventHandler; +}; + +const Input = forwardRef( + ({ value, placeholder, type = "text", hasError = false, helperText, onChange, onBlur }, ref) => { + return ( +
+ + {helperText &&

{helperText}

} +
+ ); + } +); + +Input.displayName = "Input"; + +export default Input \ No newline at end of file diff --git a/components/sign/form/PasswordInput.module.scss b/components/sign/form/PasswordInput.module.scss new file mode 100644 index 0000000000..0c7ffaecf2 --- /dev/null +++ b/components/sign/form/PasswordInput.module.scss @@ -0,0 +1,11 @@ +.container { + position: relative; + width: 100%; + display: flex; +} + +.button { + position: absolute; + top: 2.2rem; + right: 1.5rem; +} diff --git a/components/sign/form/PasswordInput.tsx b/components/sign/form/PasswordInput.tsx new file mode 100644 index 0000000000..8c2a5b9d87 --- /dev/null +++ b/components/sign/form/PasswordInput.tsx @@ -0,0 +1,61 @@ +'use client' + +import Image from "next/image"; + +import { forwardRef, useMemo, useState } from "react"; + +import EyeOnIcon from "@/public/eye-on.svg"; +import EyeOffIcon from "@/public/eye-off.svg"; + +import Input, { InputProps } from "./Input"; + +import classNames from "classnames/bind"; +import styles from "./PasswordInput.module.scss"; + +const cx = classNames.bind(styles); + +type PasswordInputProps = { + hasEyeIcon?: boolean; +} & Omit; + +const PasswordInput = forwardRef( + ( + { hasEyeIcon = false, value, placeholder, hasError = false, helperText, onChange, onBlur }, + ref + ) => { + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const inputType = useMemo(() => (isPasswordVisible ? "text" : "password"), [isPasswordVisible]); + const EyeIcon = useMemo( + () => ( + + ), + [isPasswordVisible] + ); + + return ( +
+ + {hasEyeIcon && EyeIcon} +
+ ); + } +); + +PasswordInput.displayName = "PasswordInput"; + +export default PasswordInput \ No newline at end of file diff --git a/lib/constant.ts b/lib/constant.ts new file mode 100644 index 0000000000..0367b27ee4 --- /dev/null +++ b/lib/constant.ts @@ -0,0 +1,21 @@ +export const ROUTE = { + 랜딩: "/", + 로그인: "/signin", + 회원가입: "/signup", + 폴더: "/folder", + 개인정보처리방침: "/privacy", + FAQ: "/faq", +}; + +export const PLACEHOLDER = { + email: "이메일을 입력해 주세요.", + password: "비밀번호를 입력해 주세요.", +}; + +export const ERROR_MESSAGE = { + emailRequired: "이메일을 입력해 주세요.", + emailInvalid: "올바른 이메일 주소가 아닙니다.", + emailCheck: "이메일을 확인해 주세요.", + passwordRequired: "비밀번호를 입력해 주세요.", + passwordCheck: "비밀번호를 확인해 주세요.", +}; \ No newline at end of file diff --git a/lib/searchData.ts b/lib/searchData.ts index 87f8f44478..0d9d8e0d5e 100644 --- a/lib/searchData.ts +++ b/lib/searchData.ts @@ -4,7 +4,7 @@ interface LinksData { description: string | null; } -export function filterByKeyword(links, keyword: string) { +export function filterByKeyword(links: any, keyword: string) { const lowered = keyword.toLowerCase(); const filteredLinks = links.filter(({ url, title, description }: LinksData) => diff --git a/package-lock.json b/package-lock.json index 2afd9028b2..81f1e8f6ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,13 @@ "dependencies": { "autoprefixer": "^10.4.18", "babel-plugin-styled-components": "^2.1.4", + "classnames": "^2.5.1", "next": "^14.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.51.2", + "sass": "^1.74.1", + "scss": "^0.2.4", "styled-component": "^2.8.0", "styled-components": "^6.1.8", "tailwindcss": "^3.4.1" @@ -4726,6 +4730,11 @@ "node": ">= 0.4" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", @@ -8033,6 +8042,11 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", + "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -11014,6 +11028,14 @@ "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" }, + "node_modules/ometa": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ometa/-/ometa-0.2.2.tgz", + "integrity": "sha512-LZuoK/yjU3FvrxPjUXUlZ1bavCfBPqauA7fsNdwi+AVhRdyk2IzgP3JRnevvjzQ6fKHdUw8YISshf53FmpHrng==", + "engines": { + "node": ">= 0.2.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -11997,6 +12019,21 @@ "react": "^18.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.51.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.2.tgz", + "integrity": "sha512-y++lwaWjtzDt/XNnyGDQy6goHskFualmDlf+jzEZvjvz6KWDf7EboL7pUvRCzPTJd0EOPpdekYaQLEvvG6m6HA==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -13001,6 +13038,22 @@ "which": "bin/which" } }, + "node_modules/sass": { + "version": "1.74.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz", + "integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/saxes": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/saxes/-/saxes-3.1.11.tgz", @@ -13037,6 +13090,17 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/scss": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/scss/-/scss-0.2.4.tgz", + "integrity": "sha512-4u8V87F+Q/upVhUmhPnB4C1R11xojkRkWjExL2v0CX2EXTg18VrKd+9JWoeyCp2VEMdSpJsyAvVU+rVjogh51A==", + "dependencies": { + "ometa": "0.2.2" + }, + "engines": { + "node": ">= 0.2.0" + } + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", diff --git a/package.json b/package.json index 3813ba5bbd..1b613f68fa 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,13 @@ "dependencies": { "autoprefixer": "^10.4.18", "babel-plugin-styled-components": "^2.1.4", + "classnames": "^2.5.1", "next": "^14.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.51.2", + "sass": "^1.74.1", + "scss": "^0.2.4", "styled-component": "^2.8.0", "styled-components": "^6.1.8", "tailwindcss": "^3.4.1" diff --git a/public/eye-off.svg b/public/eye-off.svg new file mode 100644 index 0000000000..802730c565 --- /dev/null +++ b/public/eye-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/eye-on.svg b/public/eye-on.svg new file mode 100644 index 0000000000..61350f1315 --- /dev/null +++ b/public/eye-on.svg @@ -0,0 +1,4 @@ + + + + diff --git a/styles/colors.scss b/styles/colors.scss new file mode 100644 index 0000000000..30ec81d0f5 --- /dev/null +++ b/styles/colors.scss @@ -0,0 +1,16 @@ +$color-primary: #6d6afe; +$color-red: #ff5b56; +$color-black: #111322; +$color-white: #ffffff; + +$color-gray100: #373740; +$color-gray60: #9fa6b2; +$color-gray20: #ccd5e3; +$color-gray10: #e7effb; +$color-gray-light: #f5f5f5; + +$color-light-blue: #f0f6ff; + +$color-text-gray: #676767; +$color-text-content-gray: #666666; +$color-text-content-black: #333333; \ No newline at end of file diff --git a/styles/global.scss b/styles/global.scss new file mode 100644 index 0000000000..7ea93de248 --- /dev/null +++ b/styles/global.scss @@ -0,0 +1,3 @@ +@import "./colors.scss"; +@import "./variables.scss"; +@import "./mixin.scss"; diff --git a/styles/mixin.scss b/styles/mixin.scss new file mode 100644 index 0000000000..721cfd6b97 --- /dev/null +++ b/styles/mixin.scss @@ -0,0 +1,24 @@ +@mixin desktop { + @media (min-width: 1200px) { + @content; + } +} + +@mixin tablet { + @media (min-width: 768px) { + @content; + } +} + +@mixin ellipsis($line: 1) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap !important; + + @if $line > 1 { + display: -webkit-box; + -webkit-line-clamp: $line; + white-space: initial !important; + -webkit-box-orient: vertical; + } +} diff --git a/styles/variables.scss b/styles/variables.scss new file mode 100644 index 0000000000..3308e47849 --- /dev/null +++ b/styles/variables.scss @@ -0,0 +1,4 @@ +$z-index-popover: 50; +$z-index-nav: 100; +$z-index-fab: 100; +$z-index-modal: 1000; From 3426721ad7166f9e8283c4b03950399a4eacafb6 Mon Sep 17 00:00:00 2001 From: whai2 Date: Tue, 9 Apr 2024 22:51:41 +0900 Subject: [PATCH 3/7] =?UTF-8?q?style:=20=EC=A0=84=EC=97=AD=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/layout.tsx | 1 + components/sign/Cta.module.scss | 12 ++++++ components/sign/Cta.tsx | 16 ++++++++ components/sign/SignInForm.tsx | 5 ++- .../sign/form/PasswordInput.module.scss | 2 + styles/reset.css | 38 +++++++++++++++++++ 6 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 components/sign/Cta.module.scss create mode 100644 components/sign/Cta.tsx create mode 100644 styles/reset.css diff --git a/app/layout.tsx b/app/layout.tsx index cf27e5f6c9..d8bda1a6a4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import './globals.css' +import "@/styles/reset.css"; const inter = Inter({ subsets: ["latin"] }); diff --git a/components/sign/Cta.module.scss b/components/sign/Cta.module.scss new file mode 100644 index 0000000000..bcfc246560 --- /dev/null +++ b/components/sign/Cta.module.scss @@ -0,0 +1,12 @@ +@import "@/styles/global.scss"; + +.container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border-radius: 0.8rem; + background: linear-gradient(91deg, $color-primary 0.12%, #6ae3fe 101.84%); + color: $color-gray-light; +} diff --git a/components/sign/Cta.tsx b/components/sign/Cta.tsx new file mode 100644 index 0000000000..9c7285e455 --- /dev/null +++ b/components/sign/Cta.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from "react"; + +import styles from "./Cta.module.scss"; +import classNames from "classnames/bind"; + +const cx = classNames.bind(styles); + +type CtaProps = { + children: ReactNode; +}; + +const Cta = ({ children }: CtaProps) => { + return
{children}
; +}; + +export default Cta \ No newline at end of file diff --git a/components/sign/SignInForm.tsx b/components/sign/SignInForm.tsx index 0a327339c9..7a1c5c2974 100644 --- a/components/sign/SignInForm.tsx +++ b/components/sign/SignInForm.tsx @@ -6,6 +6,7 @@ import { PLACEHOLDER, ERROR_MESSAGE } from "@/lib/constant"; import Input from "./form/Input"; import PasswordInput from "./form/PasswordInput"; +import Cta from "./Cta"; import styles from "./SignInForm.module.scss"; import classNames from "classnames/bind"; @@ -19,7 +20,7 @@ const SignInForm = () => { }); return ( -
+ console.log(data))}>
{ />
); diff --git a/components/sign/form/PasswordInput.module.scss b/components/sign/form/PasswordInput.module.scss index 0c7ffaecf2..83a3838d95 100644 --- a/components/sign/form/PasswordInput.module.scss +++ b/components/sign/form/PasswordInput.module.scss @@ -2,6 +2,8 @@ position: relative; width: 100%; display: flex; + justify-content: space-between; + align-items: center; } .button { diff --git a/styles/reset.css b/styles/reset.css new file mode 100644 index 0000000000..68ef2afe73 --- /dev/null +++ b/styles/reset.css @@ -0,0 +1,38 @@ +* { + box-sizing: border-box; + margin: 0; + font-family: "Pretendard"; + word-break: keep-all; +} + +html, +body { + font-size: 62.5%; +} + +a { + color: inherit; + text-decoration: none; + cursor: pointer; +} + +input { + border: none; + padding: none; +} +input:focus { + outline: none; +} +input[type="search"]::-webkit-search-decoration, +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-results-button, +input[type="search"]::-webkit-search-results-decoration { + display: none; +} + +button { + border: none; + padding: unset; + background-color: unset; + cursor: pointer; +} From 96d37c46b261cfcb6381a6d079134556f351df5e Mon Sep 17 00:00:00 2001 From: whai2 Date: Wed, 10 Apr 2024 17:27:41 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84,?= =?UTF-8?q?=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D,=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EA=B2=80=EC=A6=9D=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +-- apis/api.ts | 32 ++++++- app/(sign)/signin/page.tsx | 14 +-- app/(sign)/signup/page.tsx | 22 +++-- components/sign/SignHeader.tsx | 8 +- components/sign/SignInForm.tsx | 22 +++-- components/sign/SignUpForm.module.scss | 23 +++++ components/sign/SignUpForm.tsx | 122 +++++++++++++++++++++++++ lib/constant.ts | 51 ++++++++++- 9 files changed, 263 insertions(+), 42 deletions(-) create mode 100644 components/sign/SignUpForm.module.scss diff --git a/README.md b/README.md index 341cd22f12..87caf5bc5e 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ ## 기본 구현 사항 - [ ] 로그인/회원가입시 성공 응답으로 받은 accessToken을 로컬 스토리지에 저장합니다. - [ ] 로그인/회원가입 페이지에 접근시 로컬 스토리지에 accessToken이 있는 경우 “/folder” 페이지로 이동합니다. -- [ ] “회원 가입하기”를 클릭하면 ‘/signup’ 페이지로 이동합니다. +- [x] “회원 가입하기”를 클릭하면 ‘/signup’ 페이지로 이동합니다. - [x] 이메일 input에 placeholder는 “이메일을 입력해 주세요.”비밀번호 input에 placeholder는 “비밀번호를 입력해 주세요.”로 설정해 주세요. - [x] 이메일 input에서 focus out 할 때, 값이 없을 경우 아래에 “이메일을 입력해 주세요.” 에러 메세지를 보입니다. - [x] 이메일 input에서 focus out 할 때, 이메일 형식에 맞지 않는 값이 있는 경우 아래에 “올바른 이메일 주소가 아닙니다.” 에러 메세지를 보입니다. - [x] 비밀번호 input에서 focus out 할 때, 값이 없을 경우 아래에 “비밀번호를 입력해 주세요.” 에러 메세지를 보입니다. -- [ ] 로그인 실패하는 경우, 이메일 input 아래에 “이메일을 확인해 주세요.”, 비밀번호 input 아래에 “비밀번호를 확인해 주세요.” 에러 메세지를 보입니다. +- [x] 로그인 실패하는 경우, 이메일 input 아래에 “이메일을 확인해 주세요.”, 비밀번호 input 아래에 “비밀번호를 확인해 주세요.” 에러 메세지를 보입니다. - [ ] 로그인 버튼 클릭 또는 Enter키 입력으로 로그인 실행돼야 합니다. - [ ] https://bootcamp-api.codeit.kr/docs 에 명세된 “/api/sign-in”으로 { “email”: “test@codeit.com”, “password”: “sprint101” } POST 요청해서 성공 응답을 받으면 “/folder”로 이동합니다. - [ ] 소셜 로그인에 구글 아이콘 클릭시 ‘https://www.google.com’카카오 아이콘 클릭시 ‘https://www.kakaocorp.com/page’로 이동하게 해주세요. -- [ ] 눈 모양 아이콘 클릭시 비밀번호의 문자열이 보이기도 하고, 가려지기도 합니다. -- [ ] 비밀번호의 문자열이 가려질 때는 눈 모양 아이콘에는 사선이 그어져있고, 비밀번호의 문자열이 보일 때는 사선이 없는 눈 모양 아이콘이 보이도록 합니다. +- [x] 눈 모양 아이콘 클릭시 비밀번호의 문자열이 보이기도 하고, 가려지기도 합니다. +- [x] 비밀번호의 문자열이 가려질 때는 눈 모양 아이콘에는 사선이 그어져있고, 비밀번호의 문자열이 보일 때는 사선이 없는 눈 모양 아이콘이 보이도록 합니다. ## 심화 구현 사항 - [x] 로그인, 회원가입 기능에 react-hook-form을 활용해 주세요. @@ -33,7 +33,4 @@ - [ ] 리퀘스트 헤더에 인증 토큰을 첨부할 때 axios interceptors 또는 이와 유사한 기능을 활용해 주세요. ## 추가 구현 사항 -- [ ] swr 혹은 react-Query를 이용하기. -- [ ] server actions을 통해, 서버에서 실행하는 로직 분리하기. -- [ ] 유효성 검사, 중복 검사의 경우, swr 혹은 react-Query의 뮤테이션을 이용하기. - [ ] search 부드럽게 적용하기. (spa로 이용) \ No newline at end of file diff --git a/apis/api.ts b/apis/api.ts index 58ec966742..b4cfa1c3b6 100644 --- a/apis/api.ts +++ b/apis/api.ts @@ -1,12 +1,20 @@ +const API_URL = { + USER: "https://bootcamp-api.codeit.kr/api/users/1", + LINK: "https://bootcamp-api.codeit.kr/api/users/1/links", + FOLDER_LIST: "https://bootcamp-api.codeit.kr/api/users/1/folders", + SHARED: "https://bootcamp-api.codeit.kr/api/sample/folder", + USER_CHECK: "https://bootcamp-api.codeit.kr/api/check-email", +}; + export async function getUser() { - const response = await fetch("https://bootcamp-api.codeit.kr/api/users/1"); + const response = await fetch(API_URL.USER); const body = await response.json(); return body; } export async function getLink(folderId: string | null) { if (!folderId) { - const response = await fetch("https://bootcamp-api.codeit.kr/api/users/1/links"); + const response = await fetch(API_URL.LINK); const body = await response.json(); return body; } @@ -16,13 +24,29 @@ export async function getLink(folderId: string | null) { } export async function getFolderList() { - const response = await fetch("https://bootcamp-api.codeit.kr/api/users/1/folders"); + const response = await fetch(API_URL.FOLDER_LIST); const body = await response.json(); return body; } export async function getShared() { - const response = await fetch("https://bootcamp-api.codeit.kr/api/sample/folder"); + const response = await fetch(API_URL.SHARED); const body = await response.json(); return body; } + +export async function duplicationCheck(id: string) { + const response = await fetch(API_URL.USER_CHECK, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ email: id }), + }); + + if (response.ok === false) { + throw new Error("duplication"); + } + + return response; +} diff --git a/app/(sign)/signin/page.tsx b/app/(sign)/signin/page.tsx index c91a027572..e7e51a96ec 100644 --- a/app/(sign)/signin/page.tsx +++ b/app/(sign)/signin/page.tsx @@ -1,18 +1,18 @@ -import { ROUTE } from "@/lib/constant" +import { ROUTE } from "@/lib/constant"; -import SignHeader from '@/components/sign/SignHeader' -import SignInForm from "@/components/sign/SignInForm" +import SignHeader from "@/components/sign/SignHeader"; +import SignInForm from "@/components/sign/SignInForm"; const page = () => { return ( <> - - ) -} + ); +}; -export default page \ No newline at end of file +export default page; diff --git a/app/(sign)/signup/page.tsx b/app/(sign)/signup/page.tsx index 646ebfad0d..a2fc9e8cc6 100644 --- a/app/(sign)/signup/page.tsx +++ b/app/(sign)/signup/page.tsx @@ -1,14 +1,18 @@ -import { ROUTE } from "@/lib/constant" +import { ROUTE } from "@/lib/constant"; -import SignHeader from '@/components/sign/SignHeader' +import SignHeader from "@/components/sign/SignHeader"; +import SignUpForm from "@/components/sign/SignUpForm"; const page = () => { return ( - - ) -} + <> + + + + ); +}; -export default page \ No newline at end of file +export default page; diff --git a/components/sign/SignHeader.tsx b/components/sign/SignHeader.tsx index 5a0c2ff348..ca5ceb62b5 100644 --- a/components/sign/SignHeader.tsx +++ b/components/sign/SignHeader.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { Url } from "next/dist/shared/lib/router/router"; import LinkbraryIcon from "@/public/logo.svg"; -import { ROUTE } from "@/lib/constant" +import { ROUTE } from "@/lib/constant"; import classNames from "classnames/bind"; import styles from "./SignHeader.module.scss"; @@ -33,7 +33,7 @@ const SignHeader = ({ message, link }: SignHeaderProps) => {

- ) -} + ); +}; -export default SignHeader \ No newline at end of file +export default SignHeader; diff --git a/components/sign/SignInForm.tsx b/components/sign/SignInForm.tsx index 7a1c5c2974..bc3dc6b9bc 100644 --- a/components/sign/SignInForm.tsx +++ b/components/sign/SignInForm.tsx @@ -1,4 +1,4 @@ -'use client' +"use client"; import { Controller, useForm } from "react-hook-form"; @@ -20,20 +20,26 @@ const SignInForm = () => { }); return ( -
console.log(data))}> + console.log(data))} + >
( @@ -45,12 +51,12 @@ const SignInForm = () => { ( @@ -64,4 +70,4 @@ const SignInForm = () => { ); }; -export default SignInForm \ No newline at end of file +export default SignInForm; diff --git a/components/sign/SignUpForm.module.scss b/components/sign/SignUpForm.module.scss new file mode 100644 index 0000000000..9832427d2c --- /dev/null +++ b/components/sign/SignUpForm.module.scss @@ -0,0 +1,23 @@ +.form { + display: flex; + flex-direction: column; + row-gap: 2.4rem; + width: 100%; +} + +.label { + font-size: 1.4rem; +} + +.input-box { + display: flex; + flex-direction: column; + row-gap: 1.2rem; +} + +.button { + margin-top: 0.6rem; + height: 5.4rem; + font-size: 1.8rem; + font-weight: 600; +} diff --git a/components/sign/SignUpForm.tsx b/components/sign/SignUpForm.tsx index e69de29bb2..acd13b98b4 100644 --- a/components/sign/SignUpForm.tsx +++ b/components/sign/SignUpForm.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Controller, useForm } from "react-hook-form"; + +import { PLACEHOLDER, ERROR_MESSAGE } from "@/lib/constant"; +import { duplicationCheck } from "@/apis/api"; + +import Input from "./form/Input"; +import PasswordInput from "./form/PasswordInput"; +import Cta from "./Cta"; + +import styles from "./SignUpForm.module.scss"; +import classNames from "classnames/bind"; + +const cx = classNames.bind(styles); + +const REGEX = { + EMAIL: /\S+@\S+\.\S+/, + PASSWORD: /^(?=.*[A-Za-z])(?=.*\d).{8,}$/ +} + +const SignUpForm = () => { + const { control, handleSubmit, watch } = useForm({ + defaultValues: { email: "", password: "", confirmedPassword: "" }, + mode: "onBlur", + reValidateMode: "onBlur", + }); + + return ( + console.log(data))} + > +
+ + { + try { + const response = await duplicationCheck(watch("email")); + return true; + } catch (err) { + return ERROR_MESSAGE.signup.emailAlreadyExist; + } + }, + }, + }} + render={({ field, fieldState }) => ( + + )} + /> +
+
+ + ( + + )} + /> +
+
+ + { + if (value !== watch("password")) { + return ERROR_MESSAGE.signup.confirmedPasswordNotMatch; + } + return true; + }, + }, + }} + render={({ field, fieldState }) => ( + + )} + /> +
+ + + ); +}; + +export default SignUpForm diff --git a/lib/constant.ts b/lib/constant.ts index 0367b27ee4..c508cdad15 100644 --- a/lib/constant.ts +++ b/lib/constant.ts @@ -7,15 +7,60 @@ export const ROUTE = { FAQ: "/faq", }; -export const PLACEHOLDER = { +export const PLACEHOLDER_IN_SIGNIN = { email: "이메일을 입력해 주세요.", password: "비밀번호를 입력해 주세요.", }; -export const ERROR_MESSAGE = { +export const ERROR_MESSAGE_IN_SIGNIN = { emailRequired: "이메일을 입력해 주세요.", emailInvalid: "올바른 이메일 주소가 아닙니다.", emailCheck: "이메일을 확인해 주세요.", passwordRequired: "비밀번호를 입력해 주세요.", passwordCheck: "비밀번호를 확인해 주세요.", -}; \ No newline at end of file +}; + +export const PLACEHOLDER_IN_SIGNUP = { + email: "이메일을 입력해 주세요.", + password: "영문, 숫자를 조합해 8자 이상 입력해 주세요.", + confirmedPassword: "비밀번호와 일치하는 값을 입력해 주세요.", +}; + +export const ERROR_MESSAGE_IN_SIGNUP = { + emailRequired: "이메일을 입력해 주세요.", + emailInvalid: "올바른 이메일 주소가 아닙니다.", + emailAlreadyExist: "이미 사용 중인 이메일입니다.", + passwordInvalid: "비밀번호는 영문, 숫자 조합 8자 이상 입력해 주세요.", + confirmedPasswordNotMatch: "비밀번호가 일치하지 않아요.", +}; + +export const PLACEHOLDER = { + signin: { + email: "이메일을 입력해 주세요.", + password: "비밀번호를 입력해 주세요.", + }, + + signup: { + email: "이메일을 입력해 주세요.", + password: "영문, 숫자를 조합해 8자 이상 입력해 주세요.", + confirmedPassword: "비밀번호와 일치하는 값을 입력해 주세요.", + } +} + +export const ERROR_MESSAGE = { + signin: { + emailRequired: "이메일을 입력해 주세요.", + emailInvalid: "올바른 이메일 주소가 아닙니다.", + emailCheck: "이메일을 확인해 주세요.", + passwordRequired: "비밀번호를 입력해 주세요.", + passwordCheck: "비밀번호를 확인해 주세요.", + }, + + signup: { + emailRequired: "이메일을 입력해 주세요.", + emailInvalid: "올바른 이메일 주소가 아닙니다.", + emailAlreadyExist: "이미 사용 중인 이메일입니다.", + passwordInvalid: "비밀번호는 영문, 숫자 조합 8자 이상 입력해 주세요.", + confirmedPasswordNotMatch: "비밀번호가 일치하지 않아요.", + } +} From ae1949752b00d2fa1809289ffb99209827587d16 Mon Sep 17 00:00:00 2001 From: whai2 Date: Wed, 10 Apr 2024 17:33:56 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20=EC=83=81=EC=88=98=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/sign/SignInForm.tsx | 4 ++-- components/sign/SignUpForm.tsx | 7 +------ lib/constant.ts | 32 +++++--------------------------- 3 files changed, 8 insertions(+), 35 deletions(-) diff --git a/components/sign/SignInForm.tsx b/components/sign/SignInForm.tsx index bc3dc6b9bc..8514c8c47c 100644 --- a/components/sign/SignInForm.tsx +++ b/components/sign/SignInForm.tsx @@ -2,7 +2,7 @@ import { Controller, useForm } from "react-hook-form"; -import { PLACEHOLDER, ERROR_MESSAGE } from "@/lib/constant"; +import { PLACEHOLDER, ERROR_MESSAGE, REGEX } from "@/lib/constant"; import Input from "./form/Input"; import PasswordInput from "./form/PasswordInput"; @@ -32,7 +32,7 @@ const SignInForm = () => { rules={{ required: ERROR_MESSAGE.signin.emailRequired, pattern: { - value: /\S+@\S+\.\S+/, + value: REGEX.EMAIL, message: ERROR_MESSAGE.signin.emailInvalid, }, }} diff --git a/components/sign/SignUpForm.tsx b/components/sign/SignUpForm.tsx index acd13b98b4..20d8f8fe46 100644 --- a/components/sign/SignUpForm.tsx +++ b/components/sign/SignUpForm.tsx @@ -2,7 +2,7 @@ import { Controller, useForm } from "react-hook-form"; -import { PLACEHOLDER, ERROR_MESSAGE } from "@/lib/constant"; +import { PLACEHOLDER, ERROR_MESSAGE, REGEX } from "@/lib/constant"; import { duplicationCheck } from "@/apis/api"; import Input from "./form/Input"; @@ -14,11 +14,6 @@ import classNames from "classnames/bind"; const cx = classNames.bind(styles); -const REGEX = { - EMAIL: /\S+@\S+\.\S+/, - PASSWORD: /^(?=.*[A-Za-z])(?=.*\d).{8,}$/ -} - const SignUpForm = () => { const { control, handleSubmit, watch } = useForm({ defaultValues: { email: "", password: "", confirmedPassword: "" }, diff --git a/lib/constant.ts b/lib/constant.ts index c508cdad15..9a37d3394f 100644 --- a/lib/constant.ts +++ b/lib/constant.ts @@ -7,33 +7,6 @@ export const ROUTE = { FAQ: "/faq", }; -export const PLACEHOLDER_IN_SIGNIN = { - email: "이메일을 입력해 주세요.", - password: "비밀번호를 입력해 주세요.", -}; - -export const ERROR_MESSAGE_IN_SIGNIN = { - emailRequired: "이메일을 입력해 주세요.", - emailInvalid: "올바른 이메일 주소가 아닙니다.", - emailCheck: "이메일을 확인해 주세요.", - passwordRequired: "비밀번호를 입력해 주세요.", - passwordCheck: "비밀번호를 확인해 주세요.", -}; - -export const PLACEHOLDER_IN_SIGNUP = { - email: "이메일을 입력해 주세요.", - password: "영문, 숫자를 조합해 8자 이상 입력해 주세요.", - confirmedPassword: "비밀번호와 일치하는 값을 입력해 주세요.", -}; - -export const ERROR_MESSAGE_IN_SIGNUP = { - emailRequired: "이메일을 입력해 주세요.", - emailInvalid: "올바른 이메일 주소가 아닙니다.", - emailAlreadyExist: "이미 사용 중인 이메일입니다.", - passwordInvalid: "비밀번호는 영문, 숫자 조합 8자 이상 입력해 주세요.", - confirmedPasswordNotMatch: "비밀번호가 일치하지 않아요.", -}; - export const PLACEHOLDER = { signin: { email: "이메일을 입력해 주세요.", @@ -64,3 +37,8 @@ export const ERROR_MESSAGE = { confirmedPasswordNotMatch: "비밀번호가 일치하지 않아요.", } } + +export const REGEX = { + EMAIL: /\S+@\S+\.\S+/, + PASSWORD: /^(?=.*[A-Za-z])(?=.*\d).{8,}$/ +} From c061841c1d5b77c76c7605efd007e1bfe954a0a5 Mon Sep 17 00:00:00 2001 From: whai2 Date: Wed, 10 Apr 2024 18:43:42 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=EC=A7=80=20=EC=A0=80=EC=9E=A5,=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=9D=B4=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++---- apis/api.ts | 21 +++++++++++++++++++-- components/sign/SignInForm.tsx | 25 +++++++++++++++++++++++-- hooks/useTokenToRedirect.ts | 19 +++++++++++++++++++ 4 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 hooks/useTokenToRedirect.ts diff --git a/README.md b/README.md index 87caf5bc5e..ec32124886 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # 15 주차 ## 기본 구현 사항 -- [ ] 로그인/회원가입시 성공 응답으로 받은 accessToken을 로컬 스토리지에 저장합니다. -- [ ] 로그인/회원가입 페이지에 접근시 로컬 스토리지에 accessToken이 있는 경우 “/folder” 페이지로 이동합니다. +- [x] 로그인/회원가입시 성공 응답으로 받은 accessToken을 로컬 스토리지에 저장합니다. +- [x] 로그인/회원가입 페이지에 접근시 로컬 스토리지에 accessToken이 있는 경우 “/folder” 페이지로 이동합니다. - [x] “회원 가입하기”를 클릭하면 ‘/signup’ 페이지로 이동합니다. - [x] 이메일 input에 placeholder는 “이메일을 입력해 주세요.”비밀번호 input에 placeholder는 “비밀번호를 입력해 주세요.”로 설정해 주세요. - [x] 이메일 input에서 focus out 할 때, 값이 없을 경우 아래에 “이메일을 입력해 주세요.” 에러 메세지를 보입니다. - [x] 이메일 input에서 focus out 할 때, 이메일 형식에 맞지 않는 값이 있는 경우 아래에 “올바른 이메일 주소가 아닙니다.” 에러 메세지를 보입니다. - [x] 비밀번호 input에서 focus out 할 때, 값이 없을 경우 아래에 “비밀번호를 입력해 주세요.” 에러 메세지를 보입니다. - [x] 로그인 실패하는 경우, 이메일 input 아래에 “이메일을 확인해 주세요.”, 비밀번호 input 아래에 “비밀번호를 확인해 주세요.” 에러 메세지를 보입니다. -- [ ] 로그인 버튼 클릭 또는 Enter키 입력으로 로그인 실행돼야 합니다. -- [ ] https://bootcamp-api.codeit.kr/docs 에 명세된 “/api/sign-in”으로 { “email”: “test@codeit.com”, “password”: “sprint101” } POST 요청해서 성공 응답을 받으면 “/folder”로 이동합니다. +- [x] 로그인 버튼 클릭 또는 Enter키 입력으로 로그인 실행돼야 합니다. +- [x] https://bootcamp-api.codeit.kr/docs 에 명세된 “/api/sign-in”으로 { “email”: “test@codeit.com”, “password”: “sprint101” } POST 요청해서 성공 응답을 받으면 “/folder”로 이동합니다. - [ ] 소셜 로그인에 구글 아이콘 클릭시 ‘https://www.google.com’카카오 아이콘 클릭시 ‘https://www.kakaocorp.com/page’로 이동하게 해주세요. - [x] 눈 모양 아이콘 클릭시 비밀번호의 문자열이 보이기도 하고, 가려지기도 합니다. - [x] 비밀번호의 문자열이 가려질 때는 눈 모양 아이콘에는 사선이 그어져있고, 비밀번호의 문자열이 보일 때는 사선이 없는 눈 모양 아이콘이 보이도록 합니다. diff --git a/apis/api.ts b/apis/api.ts index b4cfa1c3b6..a701636c69 100644 --- a/apis/api.ts +++ b/apis/api.ts @@ -4,6 +4,7 @@ const API_URL = { FOLDER_LIST: "https://bootcamp-api.codeit.kr/api/users/1/folders", SHARED: "https://bootcamp-api.codeit.kr/api/sample/folder", USER_CHECK: "https://bootcamp-api.codeit.kr/api/check-email", + SIGN_IN: "https://bootcamp-api.codeit.kr/api/sign-in", }; export async function getUser() { @@ -35,13 +36,13 @@ export async function getShared() { return body; } -export async function duplicationCheck(id: string) { +export async function duplicationCheck(email: string) { const response = await fetch(API_URL.USER_CHECK, { method: "POST", headers: { "content-type": "application/json", }, - body: JSON.stringify({ email: id }), + body: JSON.stringify({ email: email }), }); if (response.ok === false) { @@ -50,3 +51,19 @@ export async function duplicationCheck(id: string) { return response; } + +export async function signIn(email: string, password: string) { + const response = await fetch(API_URL.SIGN_IN, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ email: email, password: password}), + }); + + if (response.ok === false) { + throw new Error("login failed"); + } + + return response; +} diff --git a/components/sign/SignInForm.tsx b/components/sign/SignInForm.tsx index 8514c8c47c..8d297fc25a 100644 --- a/components/sign/SignInForm.tsx +++ b/components/sign/SignInForm.tsx @@ -1,8 +1,10 @@ "use client"; +import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { PLACEHOLDER, ERROR_MESSAGE, REGEX } from "@/lib/constant"; +import { signIn } from "@/apis/api"; import Input from "./form/Input"; import PasswordInput from "./form/PasswordInput"; @@ -10,19 +12,38 @@ import Cta from "./Cta"; import styles from "./SignInForm.module.scss"; import classNames from "classnames/bind"; +import { useTokenToRedirect } from "@/hooks/useTokenToRedirect"; const cx = classNames.bind(styles); const SignInForm = () => { - const { control, handleSubmit, watch, setError } = useForm({ + const { control, handleSubmit } = useForm({ defaultValues: { email: "", password: "" }, mode: "onBlur", }); + const [token, setToken] = useState(''); + useTokenToRedirect(token); + + const onSubmitForSingIn = async (data: any) => { + try { + const loginResponse = await signIn(data?.email, data?.password); + const json = await loginResponse.json(); + + if (json?.data.accessToken) { + localStorage.setItem("accessToken", json.data.accessToken); + } + + setToken(json?.data.accessToken); + } catch (err) { + return false; + } + } + return (
console.log(data))} + onSubmit={handleSubmit(onSubmitForSingIn)} >
diff --git a/hooks/useTokenToRedirect.ts b/hooks/useTokenToRedirect.ts new file mode 100644 index 0000000000..105e14c9e8 --- /dev/null +++ b/hooks/useTokenToRedirect.ts @@ -0,0 +1,19 @@ +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +import { ROUTE } from "@/lib/constant"; + +export function useTokenToRedirect(tokenResponse?: string) { + const router = useRouter(); + + useEffect(() => { + const accessTokenInLocalStorage = localStorage.getItem("accessToken"); + const routeToFolderPage = () => { + router.replace(ROUTE.폴더); + }; + + if (accessTokenInLocalStorage === tokenResponse) { + routeToFolderPage(); + } + }, [tokenResponse, router]); +} \ No newline at end of file From f953e1abd1fef926e769987ab5c0a4d49c154f81 Mon Sep 17 00:00:00 2001 From: whai2 Date: Wed, 10 Apr 2024 18:54:35 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9E=90=EB=8F=99=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/sign/SignInForm.tsx | 3 +-- components/sign/SignUpForm.tsx | 22 +++++++++++++++++++++- hooks/useTokenToRedirect.ts | 7 ++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/components/sign/SignInForm.tsx b/components/sign/SignInForm.tsx index 8d297fc25a..bc8ccef19f 100644 --- a/components/sign/SignInForm.tsx +++ b/components/sign/SignInForm.tsx @@ -5,6 +5,7 @@ import { Controller, useForm } from "react-hook-form"; import { PLACEHOLDER, ERROR_MESSAGE, REGEX } from "@/lib/constant"; import { signIn } from "@/apis/api"; +import { useTokenToRedirect } from "@/hooks/useTokenToRedirect"; import Input from "./form/Input"; import PasswordInput from "./form/PasswordInput"; @@ -12,7 +13,6 @@ import Cta from "./Cta"; import styles from "./SignInForm.module.scss"; import classNames from "classnames/bind"; -import { useTokenToRedirect } from "@/hooks/useTokenToRedirect"; const cx = classNames.bind(styles); @@ -38,7 +38,6 @@ const SignInForm = () => { return false; } } - return ( { reValidateMode: "onBlur", }); + const [token, setToken] = useState(''); + useTokenToRedirect(token); + + const onSubmitForSingUp = async (data: any) => { + // try { + // const loginResponse = await signIn(data?.email, data?.password); + // const json = await loginResponse.json(); + + // if (json?.data.accessToken) { + // localStorage.setItem("accessToken", json.data.accessToken); + // } + + // setToken(json?.data.accessToken); + // } catch (err) { + // return false; + // } + } + return ( console.log(data))} + onSubmit={handleSubmit(onSubmitForSingUp)} >
diff --git a/hooks/useTokenToRedirect.ts b/hooks/useTokenToRedirect.ts index 105e14c9e8..286c57e6e7 100644 --- a/hooks/useTokenToRedirect.ts +++ b/hooks/useTokenToRedirect.ts @@ -12,7 +12,12 @@ export function useTokenToRedirect(tokenResponse?: string) { router.replace(ROUTE.폴더); }; - if (accessTokenInLocalStorage === tokenResponse) { + if (tokenResponse) { + routeToFolderPage(); + return; + } + + if (accessTokenInLocalStorage) { routeToFolderPage(); } }, [tokenResponse, router]);