From 403b016e5a985e95cc044176353be4a580e663bc Mon Sep 17 00:00:00 2001 From: KIMSEUNGGYU <45627868+KIMSEUNGGYU@users.noreply.github.com> Date: Sun, 29 Jan 2023 15:13:19 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20=EA=B5=AC=EA=B8=80=20oauth=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [feat] Login UI 구현 * [feat] ContentLayout UI 구현 - 로그인 화면, 온보딩과 같은 싱글 컨텐츠 레이아웃 UI 구현 * [feat] LoginPage UI 구현 * [feat] signin api 기능 및 msw 구현 * [feat] google oauth 로그인 후 헤더 UI 구현 * [feat] recoil 관련 storybook 설정 * [feat] 커먼성 UserInfo UI 구현 - User 의 정보를 나타내는 UserInfo 컴포넌트 구현 * [feat] 로그인 성공후 유저 정보를 클릭 시 나타나는 UserMenu UI 구현 * [feat] User UI 클릭 시 유저메뉴 보이는 기능 구현 * [feat] recoil localstorage effect 기능 구현 - 로그인 성공시 해당 user 정보를 localstorage 에 저장하여 영구적으로 사용할 수 있도록 설정 * [feat] google oauth redirect 기능 구현 * [fix] 🐛 nextjs Hydration failed error 수정 - recoil effect 로 localstorage 에 user 값이 있냐 없냐에 따라 로그인 상태 여부를 판단하는데 해당 기능을 추가할 때 Hydration failed 에러 발생 => 유추한 결과 window undefined 를 판단하는데 이때 초기 렌더링 UI 와 달라 발생하는 거 같음 - 해당 URL 참고하여 수정 https://stackoverflow.com/questions/71706064/react-18-hydration-failed-because-the-initial-ui-does-not-match-what-was-render/71797054#71797054 * [feat] api 없는 로그아웃 기능 구현 * [fix] 🐛 recoil 0.7.6 Duplicate atom key error 수정 - atomKey 가 고유한데 해당 이슈 발생해서 찾아보니 recoil 0.7.6 이슈 같음 https://stackoverflow.com/questions/65506656/recoil-duplicate-atom-key-in-nextjs https://github.com/facebookexperimental/Recoil/issues/733#issuecomment-1399410023 * [refactor] type 수정 * [feat] env-sample 에 NEXT_PUBLIC_GOOGLE_CLIENT_ID 형식 추가 * [chore] chromatic 배포를 위한 환경설정 값 추가 * [chore] 실수로 올린 .env 파일 제거 * [chore] 실수로 변경한 config 설정 수정 * [refactor] 코드리뷰 적용 - auth Type 수정 - resetUser 네이밍 수정 - UserInfo 컴포넌트 default type 수정 - 불필요한 코드 제거 * [refactor] login page 구조 변경 --- .env.sample | 4 +- .github/workflows/chromatic.yml | 3 +- .storybook/preview.js | 9 ++- __mocks__/apis/handlers/auth.ts | 30 ++++++++++ __mocks__/apis/handlers/index.ts | 4 +- src/apis/auth.ts | 25 ++++++++ src/apis/index.ts | 1 + src/assets/icon/google_logo.svg | 6 ++ src/assets/icon/index.tsx | 1 + .../common/Header/Header.stories.tsx | 22 +++++++ src/components/common/Header/Header.style.tsx | 15 ++++- src/components/common/Header/Header.tsx | 35 ++++++++++- .../Header/UserMenu/UserMenu.stories.tsx | 14 +++++ .../Header/UserMenu/UserMenu.styles.tsx | 45 ++++++++++++++ .../common/Header/UserMenu/UserMenu.tsx | 26 +++++++++ .../common/Header/UserMenu/index.tsx | 1 + .../SingleContentLayout.stories.tsx | 16 +++++ .../SingleContentLayout.styles.tsx | 7 +++ .../SingleContentLayout.tsx | 18 ++++++ .../common/SingleContentLayout/index.tsx | 1 + .../common/UserInfo/UserInfo.stories.tsx | 21 +++++++ .../common/UserInfo/UserInfo.styles.tsx | 32 ++++++++++ src/components/common/UserInfo/UserInfo.tsx | 38 ++++++++++++ src/components/common/UserInfo/index.tsx | 1 + src/components/login/Login/Login.stories.tsx | 14 +++++ src/components/login/Login/Login.styles.tsx | 58 +++++++++++++++++++ src/components/login/Login/Login.tsx | 38 ++++++++++++ src/components/login/Login/index.tsx | 1 + src/configs/recoil.ts | 3 + src/pages/_app.tsx | 11 ++++ src/pages/auth.ts | 40 +++++++++++++ src/pages/login/index.tsx | 14 +++++ src/pages/login/login.stories.tsx | 13 +++++ src/recoil/effects/localstorageEffect.ts | 21 +++++++ src/recoil/userSession.ts | 15 +++++ src/styles/reset.css | 3 + 36 files changed, 596 insertions(+), 10 deletions(-) create mode 100644 __mocks__/apis/handlers/auth.ts create mode 100644 src/apis/auth.ts create mode 100644 src/assets/icon/google_logo.svg create mode 100644 src/components/common/Header/UserMenu/UserMenu.stories.tsx create mode 100644 src/components/common/Header/UserMenu/UserMenu.styles.tsx create mode 100644 src/components/common/Header/UserMenu/UserMenu.tsx create mode 100644 src/components/common/Header/UserMenu/index.tsx create mode 100644 src/components/common/SingleContentLayout/SingleContentLayout.stories.tsx create mode 100644 src/components/common/SingleContentLayout/SingleContentLayout.styles.tsx create mode 100644 src/components/common/SingleContentLayout/SingleContentLayout.tsx create mode 100644 src/components/common/SingleContentLayout/index.tsx create mode 100644 src/components/common/UserInfo/UserInfo.stories.tsx create mode 100644 src/components/common/UserInfo/UserInfo.styles.tsx create mode 100644 src/components/common/UserInfo/UserInfo.tsx create mode 100644 src/components/common/UserInfo/index.tsx create mode 100644 src/components/login/Login/Login.stories.tsx create mode 100644 src/components/login/Login/Login.styles.tsx create mode 100644 src/components/login/Login/Login.tsx create mode 100644 src/components/login/Login/index.tsx create mode 100644 src/configs/recoil.ts create mode 100644 src/pages/auth.ts create mode 100644 src/pages/login/index.tsx create mode 100644 src/pages/login/login.stories.tsx create mode 100644 src/recoil/effects/localstorageEffect.ts create mode 100644 src/recoil/userSession.ts diff --git a/.env.sample b/.env.sample index bc497a6..44f4a94 100644 --- a/.env.sample +++ b/.env.sample @@ -3,4 +3,6 @@ API_PORT=3000 API_VERSION=v1 API_URL=http://$API_HOSTNAME:$API_PORT/api/$API_VERSION -NEXT_PUBLIC_API_URL=$API_URL \ No newline at end of file +NEXT_PUBLIC_API_URL=$API_URL + +NEXT_PUBLIC_GOOGLE_CLIENT_ID= diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 4b0d572..5fcc0a6 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -32,13 +32,14 @@ jobs: echo "API_VERSION=$API_VERSION" >> .env echo "API_URL=$API_URL" >> .env echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" >> .env + echo "NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID" >> .env env: API_HOSTNAME: ${{ secrets.API_HOSTNAME }} API_PORT: ${{ secrets.API_PORT }} API_VERSION: ${{ secrets.API_VERSION }} API_URL: ${{ secrets.API_URL }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} - + NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${{ secrets.NEXT_PUBLIC_GOOGLE_CLIENT_ID }} - name: Publish to Chromatic uses: chromaui/action@v1 diff --git a/.storybook/preview.js b/.storybook/preview.js index 09abc66..1978246 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { initialize, mswDecorator } from 'msw-storybook-addon'; import { RouterContext } from 'next/dist/shared/lib/router-context'; import * as NextImage from 'next/image'; +import { RecoilRoot } from 'recoil'; import '@src/styles/reset.css'; @@ -16,9 +17,11 @@ initAxiosConfig(); export const decorators = [ mswDecorator, (Story) => ( - - - + + + + + ), ]; diff --git a/__mocks__/apis/handlers/auth.ts b/__mocks__/apis/handlers/auth.ts new file mode 100644 index 0000000..b1d778a --- /dev/null +++ b/__mocks__/apis/handlers/auth.ts @@ -0,0 +1,30 @@ +import { ResponseComposition, RestContext, RestRequest, rest } from 'msw'; + +import { PostSigninResponse } from '@src/apis/auth'; +import { BASE_URL } from '@src/configs/axios'; + +const NEW_MEMBER = false; +const MEMBER = true; + +const AUTH_DATA = { + isMember: MEMBER, + jwtTokens: { + accessToken: 'access-token', + refreshToken: 'refresh-token', + }, +}; + +const signin = (req: RestRequest, res: ResponseComposition, ctx: RestContext) => { + return res( + ctx.status(201), + ctx.json({ + code: 'SUCCESS', + message: '성공', + data: AUTH_DATA, + }), + ); +}; + +export const authHandler = [ + rest.post(`${BASE_URL}/auth/signin`, signin), // +]; diff --git a/__mocks__/apis/handlers/index.ts b/__mocks__/apis/handlers/index.ts index 4ac5f38..99f7f2a 100644 --- a/__mocks__/apis/handlers/index.ts +++ b/__mocks__/apis/handlers/index.ts @@ -1,7 +1,9 @@ +import { authHandler } from './auth'; import { commentHandler } from './comment'; import { topicDetailHandler } from './topic'; export const handlers = [ - ...topicDetailHandler, // + ...authHandler, // + ...topicDetailHandler, ...commentHandler, ]; diff --git a/src/apis/auth.ts b/src/apis/auth.ts new file mode 100644 index 0000000..2c0ce2f --- /dev/null +++ b/src/apis/auth.ts @@ -0,0 +1,25 @@ +import axios from 'axios'; + +import { BaseResponse } from '.'; + +export interface Auth { + isMember: boolean; + jwtTokens: { + accessToken: string; + refreshToken: string; + }; +} + +/** + * 로그인 + */ +export type PostSigninResponse = BaseResponse; +export const signin = async (authCode: string) => { + const res = await axios.post(`/auth/signin`, null, { + headers: { + 'auth-code': authCode, + }, + }); + + return res.data.data; +}; diff --git a/src/apis/index.ts b/src/apis/index.ts index 8c7bbb5..8c834ac 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,5 +1,6 @@ export * from './topic'; export * from './comment'; +export * from './auth'; export interface BaseResponse { message: string; diff --git a/src/assets/icon/google_logo.svg b/src/assets/icon/google_logo.svg new file mode 100644 index 0000000..bd004e0 --- /dev/null +++ b/src/assets/icon/google_logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icon/index.tsx b/src/assets/icon/index.tsx index f3c46c1..840ccce 100644 --- a/src/assets/icon/index.tsx +++ b/src/assets/icon/index.tsx @@ -12,6 +12,7 @@ export { ReactComponent as Paint } from './emoji_paint.svg'; export { ReactComponent as Paper } from './emoji_paper.svg'; export { ReactComponent as Smile } from './emoji_smile.svg'; export { ReactComponent as ThumbsUp } from './emoji_thumbs_up.svg'; +export { ReactComponent as GoogleLogo } from './google_logo.svg'; export { ReactComponent as Image } from './image.svg'; export { ReactComponent as Logo } from './logo.svg'; export { ReactComponent as More } from './more.svg'; diff --git a/src/components/common/Header/Header.stories.tsx b/src/components/common/Header/Header.stories.tsx index bcc91b2..716ef49 100644 --- a/src/components/common/Header/Header.stories.tsx +++ b/src/components/common/Header/Header.stories.tsx @@ -1,4 +1,7 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { RecoilRoot } from 'recoil'; + +import $userSession from '@src/recoil/userSession'; import Header from '.'; @@ -12,3 +15,22 @@ const Template: ComponentStory = (args) =>
; export const 기본 = Template.bind({}); 기본.args = {}; + +export const 로그인 = Template.bind({}); +로그인.decorators = [ + (Story) => ( + { + set($userSession, { + isMember: true, + jwtTokens: { + accessToken: 'access-token', + refreshToken: 'refresh-token', + }, + }); + }} + > + + + ), +]; diff --git a/src/components/common/Header/Header.style.tsx b/src/components/common/Header/Header.style.tsx index e14ec59..8bc1d7a 100644 --- a/src/components/common/Header/Header.style.tsx +++ b/src/components/common/Header/Header.style.tsx @@ -11,12 +11,12 @@ export const HeaderWrapper = styled.header` z-index: 1; height: 5rem; - color: ${theme.color.Primary1}; background-color: ${theme.color.G1}; border-bottom: 1px solid ${theme.color.Primary1}; `; export const HeaderContents = styled.div` + position: relative; margin: 0 auto; max-width: 1200px; display: flex; @@ -43,4 +43,17 @@ export const Menu = styled.li` font-weight: 700; font-size: 15px; line-height: 150%; + + text-decoration: none; + color: ${theme.color.Primary1}; +`; + +export const UserInfoWrapper = styled.div` + cursor: pointer; +`; + +export const UserMenuWrapper = styled.div` + position: absolute; + top: 70px; + right: 0; `; diff --git a/src/components/common/Header/Header.tsx b/src/components/common/Header/Header.tsx index 2bc8248..21d5c99 100644 --- a/src/components/common/Header/Header.tsx +++ b/src/components/common/Header/Header.tsx @@ -1,11 +1,27 @@ import Link from 'next/link'; -import { FC } from 'react'; +import { FC, useState } from 'react'; +import { useRecoilValue, useResetRecoilState } from 'recoil'; import Icon from '@src/components/common/Icon'; +import UserInfo from '@src/components/common/UserInfo'; +import $userSession from '@src/recoil/userSession'; import * as S from './Header.style'; +import UserMenu from './UserMenu'; const Header: FC = () => { + const userSession = useRecoilValue($userSession); + const resetUser = useResetRecoilState($userSession); + const [viewUserMenu, setViewUserMenu] = useState(false); + + const isLogin = !!userSession; + + const handleLogout = () => { + resetUser(); + setViewUserMenu(false); + }; + + // TODO-GYU: backend 와 논의 후 user 정보를 어떻게 받아올지 처리 return ( @@ -14,10 +30,23 @@ const Header: FC = () => { - + - 로그인 + {isLogin ? ( + setViewUserMenu((prev) => !prev)}> + + + ) : ( + + 로그인 + + )} + {viewUserMenu && ( + + + + )} ); diff --git a/src/components/common/Header/UserMenu/UserMenu.stories.tsx b/src/components/common/Header/UserMenu/UserMenu.stories.tsx new file mode 100644 index 0000000..fe1396a --- /dev/null +++ b/src/components/common/Header/UserMenu/UserMenu.stories.tsx @@ -0,0 +1,14 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; + +import UserMenu from './UserMenu'; + +export default { + title: 'common/UserMenu', + component: UserMenu, + args: {}, +} as ComponentMeta; + +const Template: ComponentStory = ({ ...args }) => ; + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/src/components/common/Header/UserMenu/UserMenu.styles.tsx b/src/components/common/Header/UserMenu/UserMenu.styles.tsx new file mode 100644 index 0000000..c30bdf6 --- /dev/null +++ b/src/components/common/Header/UserMenu/UserMenu.styles.tsx @@ -0,0 +1,45 @@ +import styled from '@emotion/styled'; + +import theme from '@src/styles/theme'; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + width: 227px; + height: 182px; + box-shadow: 0px 4px 30px 5px rgba(0, 0, 0, 0.15); + border-radius: 8px; +`; + +export const UserItem = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 16px; + height: 76px; + + border-radius: 8px 8px 0 0; + background: ${theme.color.G4}; + &:hover { + background: ${theme.color.G5}; + cursor: pointer; + } +`; + +export const MenuItem = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 16px; + height: 53px; + + background: ${theme.color.G4}; + &:hover { + background: ${theme.color.G5}; + cursor: pointer; + } + &:last-child { + border-radius: 0 0 8px 8px; + } +`; diff --git a/src/components/common/Header/UserMenu/UserMenu.tsx b/src/components/common/Header/UserMenu/UserMenu.tsx new file mode 100644 index 0000000..81c06b5 --- /dev/null +++ b/src/components/common/Header/UserMenu/UserMenu.tsx @@ -0,0 +1,26 @@ +import React, { FC } from 'react'; + +import Icon from '@src/components/common/Icon'; +import UserInfo from '@src/components/common/UserInfo'; + +import * as S from './UserMenu.styles'; + +interface Props { + onLogout: () => void; +} +const UserMenu: FC = (props) => { + const { onLogout } = props; + + return ( + + + + + + 문의하기 + 로그아웃 + + ); +}; + +export default UserMenu; diff --git a/src/components/common/Header/UserMenu/index.tsx b/src/components/common/Header/UserMenu/index.tsx new file mode 100644 index 0000000..63aab39 --- /dev/null +++ b/src/components/common/Header/UserMenu/index.tsx @@ -0,0 +1 @@ +export { default } from './UserMenu'; diff --git a/src/components/common/SingleContentLayout/SingleContentLayout.stories.tsx b/src/components/common/SingleContentLayout/SingleContentLayout.stories.tsx new file mode 100644 index 0000000..f8f2c4c --- /dev/null +++ b/src/components/common/SingleContentLayout/SingleContentLayout.stories.tsx @@ -0,0 +1,16 @@ +import { ComponentStory } from '@storybook/react'; +import React from 'react'; + +import SingleContentLayout from '.'; + +export default { + component: SingleContentLayout, + title: 'common/SingleContentLayout', +}; + +const Template: ComponentStory = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + children: '컨텐츠', +}; diff --git a/src/components/common/SingleContentLayout/SingleContentLayout.styles.tsx b/src/components/common/SingleContentLayout/SingleContentLayout.styles.tsx new file mode 100644 index 0000000..9e4cb3e --- /dev/null +++ b/src/components/common/SingleContentLayout/SingleContentLayout.styles.tsx @@ -0,0 +1,7 @@ +import styled from '@emotion/styled'; + +export const Wrapper = styled.div` + display: flex; + justify-content: center; + margin-top: 206px; +`; diff --git a/src/components/common/SingleContentLayout/SingleContentLayout.tsx b/src/components/common/SingleContentLayout/SingleContentLayout.tsx new file mode 100644 index 0000000..020c75c --- /dev/null +++ b/src/components/common/SingleContentLayout/SingleContentLayout.tsx @@ -0,0 +1,18 @@ +import React, { FC, PropsWithChildren } from 'react'; + +import Header from '@src/components/common/Header'; + +import * as S from './SingleContentLayout.styles'; + +const ContentLayout: FC = (props) => { + const { children } = props; + + return ( + <> +
+ {children} + + ); +}; + +export default ContentLayout; diff --git a/src/components/common/SingleContentLayout/index.tsx b/src/components/common/SingleContentLayout/index.tsx new file mode 100644 index 0000000..fb3e7f2 --- /dev/null +++ b/src/components/common/SingleContentLayout/index.tsx @@ -0,0 +1 @@ +export { default } from './SingleContentLayout'; diff --git a/src/components/common/UserInfo/UserInfo.stories.tsx b/src/components/common/UserInfo/UserInfo.stories.tsx new file mode 100644 index 0000000..027bcc4 --- /dev/null +++ b/src/components/common/UserInfo/UserInfo.stories.tsx @@ -0,0 +1,21 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; + +import UserInfo from './UserInfo'; + +export default { + title: 'common/UserInfo', + component: UserInfo, + args: {}, +} as ComponentMeta; + +const Template: ComponentStory = ({ ...args }) => ; + +export const simple = Template.bind({}); +simple.args = { + type: 'simple', +}; + +export const full = Template.bind({}); +full.args = { + type: 'full', +}; diff --git a/src/components/common/UserInfo/UserInfo.styles.tsx b/src/components/common/UserInfo/UserInfo.styles.tsx new file mode 100644 index 0000000..66f9e68 --- /dev/null +++ b/src/components/common/UserInfo/UserInfo.styles.tsx @@ -0,0 +1,32 @@ +import styled from '@emotion/styled'; +import Image from 'next/image'; + +import theme from '@src/styles/theme'; + +export const Wrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + padding: 0px; + gap: 8px; +`; + +export const Profile = styled(Image)` + background: transparent; + background-color: ${theme.color.G7}; + border-radius: 50%; +`; + +export const SummaryWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 2px; +`; + +export const UserNickName = styled.p` + font-size: ${theme.textSize.B2}; +`; + +export const UserInfo = styled.p` + font-size: ${theme.textSize.B3}; +`; diff --git a/src/components/common/UserInfo/UserInfo.tsx b/src/components/common/UserInfo/UserInfo.tsx new file mode 100644 index 0000000..bdf4e2e --- /dev/null +++ b/src/components/common/UserInfo/UserInfo.tsx @@ -0,0 +1,38 @@ +import React, { FC } from 'react'; + +import { MEMBER } from '@mocks/data/member'; + +import { Member } from '@src/apis'; +import DefaultImage from '@src/assets/user-default.png'; + +import * as S from './UserInfo.styles'; + +// TODO-GYU: 로그인 관련해서 user api 방식에 따라 달라질 예정 +// 우선 Member 의 MockData 로 처리 +interface Props { + type?: 'simple' | 'full'; + member?: Member; +} + +const UserInfo: FC = (props) => { + const { type = 'full', member = MEMBER } = props; + + const size = type === 'full' ? 44 : 28; + + const { name, jobCategory, workingYears, profileImage } = member; + return ( + + + + {name} + {type === 'full' && ( + + {jobCategory}·{workingYears}년차 + + )} + + + ); +}; + +export default UserInfo; diff --git a/src/components/common/UserInfo/index.tsx b/src/components/common/UserInfo/index.tsx new file mode 100644 index 0000000..fcf3235 --- /dev/null +++ b/src/components/common/UserInfo/index.tsx @@ -0,0 +1 @@ +export { default } from './UserInfo'; diff --git a/src/components/login/Login/Login.stories.tsx b/src/components/login/Login/Login.stories.tsx new file mode 100644 index 0000000..02f4810 --- /dev/null +++ b/src/components/login/Login/Login.stories.tsx @@ -0,0 +1,14 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; + +import Login from './Login'; + +export default { + title: 'login/Login', + component: Login, + args: {}, +} as ComponentMeta; + +const Template: ComponentStory = ({ ...args }) => ; + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/src/components/login/Login/Login.styles.tsx b/src/components/login/Login/Login.styles.tsx new file mode 100644 index 0000000..19878ef --- /dev/null +++ b/src/components/login/Login/Login.styles.tsx @@ -0,0 +1,58 @@ +import styled from '@emotion/styled'; + +import theme from '@src/styles/theme'; + +export const Wrapper = styled.main` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 519px; + width: 562px; + background-color: ${theme.color.G2}; + padding: 24px; + border-radius: 8px; +`; + +export const Title = styled.p` + margin-bottom: 20px; + color: ${theme.color.Primary1}; + font-family: ${theme.fontFamily.english}; + font-size: 28px; + text-align: center; +`; + +export const Summary = styled.p` + color: ${theme.color.G8}; + font-size: ${theme.textSize.B1}; + line-height: ${theme.lineHeight.B}; +`; + +export const GoogleLogin = styled.a` + display: flex; + justify-content: center; + align-items: center; + height: 50px; + width: 260px; + margin-top: 50px; + margin-bottom: 16px; + padding: 2px 6px; + border-radius: 4px; + background: ${theme.color.G8}; + gap: 10px; + font-size: ${theme.textSize.B2}; + line-height: ${theme.lineHeight.B}; + color: ${theme.color.G1}; + text-decoration: none; + &:hover { + cursor: pointer; + color: ${theme.color.G1}; + } +`; + +export const Terms = styled.p` + font-size: ${theme.textSize.B4}; + line-height: ${theme.lineHeight.B}; + text-align: center; + color: ${theme.color.G7}; +`; diff --git a/src/components/login/Login/Login.tsx b/src/components/login/Login/Login.tsx new file mode 100644 index 0000000..42aa4fe --- /dev/null +++ b/src/components/login/Login/Login.tsx @@ -0,0 +1,38 @@ +import React, { FC } from 'react'; + +import Icon from '@src/components/common/Icon'; + +import * as S from './Login.styles'; + +const Login: FC = () => { + return ( + + Hello There! + 반갑습니다 유저님! + Thumbs UP에 간편하게 로그인하고 토픽에 참여해보세요 + + + Google 계정으로 로그인 + + 로그인은 서비스 이용약관 및 개인정보 처리방침에 동의함을 의미하며, + 서비스 이용을 위해 이메일과 이름, 프로필 이미지를 수집합니다. + + ); +}; + +const getGoogleUrl = () => { + const rootUrl = 'https://accounts.google.com/o/oauth2/v2/auth'; + + const options = { + redirect_uri: 'http://localhost:3000/auth', + scope: 'email', + response_type: 'code', + client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID as string, + }; + + const qs = new URLSearchParams(options); + + return `${rootUrl}?${qs.toString()}`; +}; + +export default Login; diff --git a/src/components/login/Login/index.tsx b/src/components/login/Login/index.tsx new file mode 100644 index 0000000..2a741cd --- /dev/null +++ b/src/components/login/Login/index.tsx @@ -0,0 +1 @@ +export { default } from './Login'; diff --git a/src/configs/recoil.ts b/src/configs/recoil.ts new file mode 100644 index 0000000..f546827 --- /dev/null +++ b/src/configs/recoil.ts @@ -0,0 +1,3 @@ +import { RecoilEnv } from 'recoil'; + +RecoilEnv.RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED = false; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index cc8f9c8..2357d17 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,6 +1,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import type { AppProps } from 'next/app'; +import { useEffect, useState } from 'react'; import { RecoilRoot } from 'recoil'; import '@src/styles/reset.css'; @@ -8,6 +9,7 @@ import '@src/styles/reset.css'; import '@src/styles/common.css'; import { initAxiosConfig } from '@src/configs/axios'; +import '@src/configs/recoil'; import queryClient from '../configs/queryClient'; @@ -27,6 +29,15 @@ if (process.env.NODE_ENV === 'development') { } function MyApp({ Component, pageProps }: AppProps) { + const [domLoaded, setDomLoaded] = useState(false); + useEffect(() => { + setDomLoaded(true); + }, []); + + if (!domLoaded || typeof window === 'undefined') { + return null; + } + return ( diff --git a/src/pages/auth.ts b/src/pages/auth.ts new file mode 100644 index 0000000..0dc39ef --- /dev/null +++ b/src/pages/auth.ts @@ -0,0 +1,40 @@ +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; +import { useSetRecoilState } from 'recoil'; + +import { signin } from '@src/apis'; +import $userSession from '@src/recoil/userSession'; + +const Auth = () => { + const router = useRouter(); + const setUserSession = useSetRecoilState($userSession); + + const code = router.query.code as string; + + useEffect(() => { + if (!code) return; + + const googleOauth = async () => { + const result = await signin(code); + + // 에러 인 경우 + if (!result) return; + + if (result.isMember === true) { + // 이미 가입된 유저로 홈으로 이동 + // 로그인 성공 + // user 상태 관리! + setUserSession(result); + + router.push('/'); + } else { + // onboarding 으로 이동 + // router.push('/onboardin'); + } + }; + + googleOauth(); + }, [code, router, setUserSession]); +}; + +export default Auth; diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx new file mode 100644 index 0000000..63268d3 --- /dev/null +++ b/src/pages/login/index.tsx @@ -0,0 +1,14 @@ +import type { NextPage } from 'next'; + +import SingleContentLayout from '@src/components/common/SingleContentLayout'; +import Login from '@src/components/login/Login'; + +const LoginPage: NextPage = () => { + return ( + + + + ); +}; + +export default LoginPage; diff --git a/src/pages/login/login.stories.tsx b/src/pages/login/login.stories.tsx new file mode 100644 index 0000000..29859b1 --- /dev/null +++ b/src/pages/login/login.stories.tsx @@ -0,0 +1,13 @@ +import { ComponentStory } from '@storybook/react'; +import React from 'react'; + +import Login from './'; + +export default { + component: Login, + title: 'pages/Login', +}; + +const Template: ComponentStory = (args) => ; + +export const Default = Template.bind({}); diff --git a/src/recoil/effects/localstorageEffect.ts b/src/recoil/effects/localstorageEffect.ts new file mode 100644 index 0000000..2c8379c --- /dev/null +++ b/src/recoil/effects/localstorageEffect.ts @@ -0,0 +1,21 @@ +import { AtomEffect } from 'recoil'; + +const localStorageEffect: (key: string) => AtomEffect = + (key: string) => + ({ setSelf, onSet }) => { + if (typeof window === 'undefined') { + return; + } + + const savedValue = localStorage.getItem(key); + + if (savedValue) { + setSelf(JSON.parse(savedValue)); + } + + onSet((newValue, _, isReset) => { + isReset ? localStorage.removeItem(key) : localStorage.setItem(key, JSON.stringify(newValue)); + }); + }; + +export default localStorageEffect; diff --git a/src/recoil/userSession.ts b/src/recoil/userSession.ts new file mode 100644 index 0000000..d6c00bb --- /dev/null +++ b/src/recoil/userSession.ts @@ -0,0 +1,15 @@ +import { atom } from 'recoil'; + +// TODO-GYU: user 값이 무엇인지에 따라 타입 변경해야함! (API 나오면 확인하기) +// 임시로 Auth로 처리 +import { Auth } from '@src/apis/auth'; + +import localStorageEffect from './effects/localstorageEffect'; + +const $userSession = atom({ + key: 'user-sesseion', + default: undefined, + effects: [localStorageEffect('user')], +}); + +export default $userSession; diff --git a/src/styles/reset.css b/src/styles/reset.css index cb8e3b6..922bbb2 100644 --- a/src/styles/reset.css +++ b/src/styles/reset.css @@ -117,3 +117,6 @@ button { background-color: transparent; font-family: 'Pretendard-Regular', 'Noto Sans CJK KR', syne; } +a { + text-decoration: none; +}