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;
+}