diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json index ab8fc7934..0f385ba75 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -9,6 +9,7 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "stylelint.enable": true, + "stylelint.config": null, "stylelint.validate": ["css", "scss", "typescript", "typescriptreact"] } diff --git a/frontend/package.json b/frontend/package.json index 913d3ac0f..9e6784510 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "main": "index.tsx", "license": "MIT", "scripts": { - "start": "webpack serve --open --mode development", + "start": "webpack serve --open --mode development --port 3000", "start:prod": "webpack serve --open --mode production", "build": "webpack --mode production", "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", @@ -32,6 +32,7 @@ "msw": "^1.2.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.14.2", "storybook": "^7.0.25", "styled-components": "^6.0.2", "ts-loader": "^9.4.4", diff --git a/frontend/public/index.html b/frontend/public/index.html index b3789f56e..615b5e221 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -11,5 +11,6 @@

경고!

현재 사용 중인 브라우저는 스크립트를 지원하지 않거나, 해당 기능이 활성화 되어 있지 않습니다.
+ diff --git a/frontend/src/@types/oauth.types.ts b/frontend/src/@types/oauth.types.ts new file mode 100644 index 000000000..3dbe5eb87 --- /dev/null +++ b/frontend/src/@types/oauth.types.ts @@ -0,0 +1 @@ +export type Oauth = 'google' | 'kakao' | 'naver'; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 902884ad0..f5eb41aa3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,20 @@ -import MainPage from './pages/MainPage'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import OauthRedirectPage from '~/pages/OauthRedirectPage'; +import MainPage from '~/pages/MainPage'; export const { BASE_URL } = process.env; function App() { - return ; + return ( + + + } /> + } /> + } /> + } /> + + + ); } export default App; diff --git a/frontend/src/assets/icons/etc/menu.svg b/frontend/src/assets/icons/etc/menu.svg new file mode 100644 index 000000000..f2063d991 --- /dev/null +++ b/frontend/src/assets/icons/etc/menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/etc/user.svg b/frontend/src/assets/icons/etc/user.svg new file mode 100644 index 000000000..23456087a --- /dev/null +++ b/frontend/src/assets/icons/etc/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/oauth/google.svg b/frontend/src/assets/icons/oauth/google.svg new file mode 100644 index 000000000..138004410 --- /dev/null +++ b/frontend/src/assets/icons/oauth/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/oauth/kakao.svg b/frontend/src/assets/icons/oauth/kakao.svg new file mode 100644 index 000000000..1915a9b39 --- /dev/null +++ b/frontend/src/assets/icons/oauth/kakao.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/icons/oauth/naver.svg b/frontend/src/assets/icons/oauth/naver.svg new file mode 100644 index 000000000..a9c147926 --- /dev/null +++ b/frontend/src/assets/icons/oauth/naver.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/components/@common/Header/Header.tsx b/frontend/src/components/@common/Header/Header.tsx index 4d43da815..f8e6541e6 100644 --- a/frontend/src/components/@common/Header/Header.tsx +++ b/frontend/src/components/@common/Header/Header.tsx @@ -1,11 +1,37 @@ +import React from 'react'; import { styled } from 'styled-components'; import Logo from '~/assets/logo.png'; +import { Modal, ModalContent } from '~/components/@common/Modal'; +import InfoDropDown from '~/components/InfoDropDown'; +import LoginModalContent from '~/components/LoginModalContent'; +import useBooleanState from '~/hooks/useBooleanState'; + +const options = [ + { id: 1, value: '로그인' }, + { id: 2, value: '회원가입' }, +]; function Header() { + const { value: isModalOpen, setTrue: openModal, setFalse: closeModal } = useBooleanState(false); + + const handleInfoDropDown = (event: React.MouseEvent) => { + const currentOption = event.currentTarget.dataset.name; + + if (currentOption === '로그인') openModal(); + }; + return ( - - - + <> + + + + + + + + + + ); } @@ -13,11 +39,12 @@ export default Header; const StyledHeader = styled.header` display: flex; + justify-content: space-between; align-items: center; position: sticky; top: 0; - z-index: 10; + z-index: 20; width: 100%; height: 80px; diff --git a/frontend/src/components/@common/InfoButton/InfoButton.stories.tsx b/frontend/src/components/@common/InfoButton/InfoButton.stories.tsx new file mode 100644 index 000000000..4be75506c --- /dev/null +++ b/frontend/src/components/@common/InfoButton/InfoButton.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import InfoButton from './InfoButton'; + +const meta: Meta = { + title: 'InfoButton', + component: InfoButton, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/frontend/src/components/@common/InfoButton/InfoButton.tsx b/frontend/src/components/@common/InfoButton/InfoButton.tsx new file mode 100644 index 000000000..10f5f0ad2 --- /dev/null +++ b/frontend/src/components/@common/InfoButton/InfoButton.tsx @@ -0,0 +1,47 @@ +import styled, { css } from 'styled-components'; + +import Menu from '~/assets/icons/etc/menu.svg'; +import User from '~/assets/icons/etc/user.svg'; + +interface InfoButtonProps { + isShow?: boolean; +} + +function InfoButton({ isShow = false }: InfoButtonProps) { + return ( + + + + + ); +} + +export default InfoButton; + +const StyledInfoButton = styled.button` + display: flex; + justify-content: space-between; + align-items: center; + + width: 77px; + + padding: 0.5rem 0.5rem 0.5rem 1.2rem; + + border: 1px solid #ddd; + border-radius: 21px; + background: transparent; + + cursor: pointer; + + ${({ isShow }) => + isShow && + css` + box-shadow: var(--shadow); + `} + + &:hover { + box-shadow: var(--shadow); + + transition: box-shadow 0.2s ease-in-out; + } +`; diff --git a/frontend/src/components/@common/InfoButton/index.tsx b/frontend/src/components/@common/InfoButton/index.tsx new file mode 100644 index 000000000..dac566eef --- /dev/null +++ b/frontend/src/components/@common/InfoButton/index.tsx @@ -0,0 +1,3 @@ +import InfoButton from '~/components/@common/InfoButton/InfoButton'; + +export default InfoButton; diff --git a/frontend/src/components/@common/LoginButton/LoginButton.stories.tsx b/frontend/src/components/@common/LoginButton/LoginButton.stories.tsx new file mode 100644 index 000000000..52cd8c2e0 --- /dev/null +++ b/frontend/src/components/@common/LoginButton/LoginButton.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import LoginButton from './LoginButton'; + +const meta: Meta = { + title: 'Oauth/LoginButton', + component: LoginButton, + decorators: [ + Story => ( + + + } /> + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Google: Story = { + args: { type: 'google' }, +}; + +export const KaKao: Story = { + args: { type: 'kakao' }, +}; + +export const Naver: Story = { + args: { type: 'naver' }, +}; diff --git a/frontend/src/components/@common/LoginButton/LoginButton.tsx b/frontend/src/components/@common/LoginButton/LoginButton.tsx new file mode 100644 index 000000000..4fd1b0e19 --- /dev/null +++ b/frontend/src/components/@common/LoginButton/LoginButton.tsx @@ -0,0 +1,74 @@ +import { Link } from 'react-router-dom'; +import styled, { css } from 'styled-components'; +import React from 'react'; +import { OAUTH_BUTTON_MESSAGE, OAUTH_LINK } from '~/constants/api'; + +import KaKao from '~/assets/icons/oauth/kakao.svg'; +import Naver from '~/assets/icons/oauth/naver.svg'; +import Google from '~/assets/icons/oauth/google.svg'; +import { Oauth } from '~/@types/oauth.types'; + +interface LoginButtonProps { + type: Oauth; +} + +const LoginIcon: Record = { + naver: , + kakao: , + google: , +}; + +function LoginButton({ type }: LoginButtonProps) { + return ( + +
{LoginIcon[type]}
+ {OAUTH_BUTTON_MESSAGE[type]} +
+ ); +} + +export default LoginButton; + +const StyledLoginButtonWrapper = styled(Link)` + display: flex; + + width: 100%; + height: fit-content; + + padding: 2.3rem 1.3rem; + + border-radius: 12px; + + font-size: 1.4rem; + font-weight: 600; + text-decoration: none; + + ${({ type }) => + type === 'naver' && + css` + background: #03c759; + + color: #fff; + `} + + ${({ type }) => + type === 'kakao' && + css` + background: #fee500; + `} + + ${({ type }) => + type === 'google' && + css` + border: 1px solid var(--gray-3); + `} + + cursor: pointer; + transition: box-shadow 0.2s cubic-bezier(0.2, 0, 0, 1), transform 0.1s cubic-bezier(0.2, 0, 0, 1); +`; + +const StyledLoginButtonText = styled.span` + margin: 0 auto; + + color: inherit; +`; diff --git a/frontend/src/components/@common/LoginButton/index.tsx b/frontend/src/components/@common/LoginButton/index.tsx new file mode 100644 index 000000000..9ee6be126 --- /dev/null +++ b/frontend/src/components/@common/LoginButton/index.tsx @@ -0,0 +1,3 @@ +import LoginButton from '~/components/@common/LoginButton/LoginButton'; + +export default LoginButton; diff --git a/frontend/src/components/@common/Modal/Modal.tsx b/frontend/src/components/@common/Modal/Modal.tsx new file mode 100644 index 000000000..b5eb84500 --- /dev/null +++ b/frontend/src/components/@common/Modal/Modal.tsx @@ -0,0 +1,11 @@ +import { createPortal } from 'react-dom'; + +interface ModalProps { + children: React.ReactNode; +} + +function Modal({ children }: ModalProps) { + return createPortal(children, document.querySelector('#modal')); +} + +export default Modal; diff --git a/frontend/src/components/@common/Modal/ModalContent.stories.tsx b/frontend/src/components/@common/Modal/ModalContent.stories.tsx new file mode 100644 index 000000000..fc7ce1800 --- /dev/null +++ b/frontend/src/components/@common/Modal/ModalContent.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ModalContent from './ModalContent'; + +const meta: Meta = { + title: 'ModalContent', + component: ModalContent, +}; + +export default meta; + +type Story = StoryObj; + +export const LoginModal: Story = { + args: { + isShow: true, + title: '로그인 또는 회원가입', + closeModal: () => {}, + children: '모달 내용', + }, +}; diff --git a/frontend/src/components/@common/Modal/ModalContent.tsx b/frontend/src/components/@common/Modal/ModalContent.tsx new file mode 100644 index 000000000..fbab17a16 --- /dev/null +++ b/frontend/src/components/@common/Modal/ModalContent.tsx @@ -0,0 +1,106 @@ +import styled, { css } from 'styled-components'; +import Exit from '~/assets/icons/exit.svg'; + +interface ModalContentProps { + isShow?: boolean; + title: string; + closeModal: () => void; + children: React.ReactNode; +} + +function ModalContent({ isShow = false, title, closeModal, children }: ModalContentProps) { + return ( + + + + + + {title} + + {children} + + + ); +} + +export default ModalContent; + +const StyledModalContentWrapper = styled.div<{ isShow: boolean }>` + display: flex; + justify-content: center; + align-items: center; + + position: fixed; + top: 0; + left: 0; + z-index: 999; + + width: 100%; + height: 100%; + + opacity: 0; + visibility: hidden; + + ${({ isShow }) => + isShow && + css` + visibility: visible; + + opacity: 1; + transition: opacity ease 0.25s; + `} +`; + +const StyledModalOverlay = styled.div` + position: absolute; + top: 0; + left: 0; + z-index: 1; + + width: 100%; + height: 100%; + + background: rgb(0 0 0 / 50%); +`; + +const StyledModalContent = styled.div<{ isShow: boolean }>` + display: flex; + flex-direction: column; + + position: relative; + z-index: 10; + + width: 33%; + min-width: 500px; + max-width: 600px; + min-height: 100px; + + padding: 2rem; + + border-radius: 5px; + background: #fff; + + transition: transform ease 0.3s 0.1s; + transform: translateY(80px); + + overflow-y: auto; + + ${({ isShow }) => + isShow && + css` + transform: translateY(0); + `} +`; + +const StyledModalHeader = styled.h5` + display: flex; + align-items: center; +`; + +const StyledModalTitleText = styled.span` + margin: 0 auto; +`; + +const StyledModalBody = styled.div` + margin-top: 2.4rem; +`; diff --git a/frontend/src/components/@common/Modal/index.tsx b/frontend/src/components/@common/Modal/index.tsx new file mode 100644 index 000000000..4caa2d0b2 --- /dev/null +++ b/frontend/src/components/@common/Modal/index.tsx @@ -0,0 +1,4 @@ +import Modal from '~/components/@common/Modal/Modal'; +import ModalContent from '~/components/@common/Modal/ModalContent'; + +export { Modal, ModalContent }; diff --git a/frontend/src/components/InfoDropDown/InfoDropDown.stories.tsx b/frontend/src/components/InfoDropDown/InfoDropDown.stories.tsx new file mode 100644 index 000000000..1c62ce2f5 --- /dev/null +++ b/frontend/src/components/InfoDropDown/InfoDropDown.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import InfoDropDown from './InfoDropDown'; + +const meta: Meta = { + title: 'Selector/InfoDropDown', + component: InfoDropDown, +}; + +export default meta; + +const options = [ + { + id: 1, + value: '로그인', + }, + { + id: 2, + value: '회원가입', + }, + { + id: 3, + value: '기타', + }, + { + id: 4, + value: '등 등', + }, +]; + +type Story = StoryObj; + +export const Default: Story = { + args: { + options, + }, +}; diff --git a/frontend/src/components/InfoDropDown/InfoDropDown.tsx b/frontend/src/components/InfoDropDown/InfoDropDown.tsx new file mode 100644 index 000000000..d757dcc98 --- /dev/null +++ b/frontend/src/components/InfoDropDown/InfoDropDown.tsx @@ -0,0 +1,119 @@ +import styled from 'styled-components'; +import { MouseEvent } from 'react'; +import InfoButton from '~/components/@common/InfoButton'; +import useBooleanState from '~/hooks/useBooleanState'; + +interface Option { + id: number; + value: string; +} + +interface DropDownProps { + options: Option[]; + isOpen?: boolean; + externalOnClick?: (e?: React.MouseEvent) => void; +} + +function InfoDropDown({ options, externalOnClick, isOpen = false }: DropDownProps) { + const { value: isShow, toggle: onToggleDropDown, setFalse: onCloseDropDown } = useBooleanState(isOpen); + + const onSelection = () => (event?: MouseEvent) => { + if (externalOnClick) externalOnClick(event); + }; + + return ( + + + + + + {isShow && ( + + + {options.map(({ id, value }) => ( + + {value} + + ))} + + + )} + + ); +} + +export default InfoDropDown; + +const StyledInfoButtonWrapper = styled.button` + border: none; + background: transparent; + + cursor: pointer; + outline: none; +`; + +const StyledInfoDropDown = styled.div` + display: relative; + + z-index: 100000000; + + width: 77px; + height: 42px; +`; + +const StyledDropDownWrapper = styled.ul` + display: flex; + flex-direction: column; + align-content: center; + + position: absolute; + top: calc(100% - 8px); + right: 18px; + + width: 216px; + height: 176px; + + padding: 1.8rem 0; + + border-radius: 10px; + background: white; + + font-size: 1.4rem; + + box-shadow: var(--shadow); +`; + +const StyledSelectContainer = styled.div` + width: 100%; + height: 150px; + + background: transparent; + + overflow-y: auto; +`; + +const StyledDropDownOption = styled.li` + display: flex; + justify-content: space-between; + align-items: center; + + height: 44px; + + margin: 0 1.8rem; + + cursor: pointer; + + & + & { + border-bottom: 1px solid var(--gray-1); + } + + &:first-child { + border-bottom: 1px solid var(--gray-1); + } + + & > div { + display: flex; + align-items: center; + gap: 0.4rem; + } +`; diff --git a/frontend/src/components/InfoDropDown/index.tsx b/frontend/src/components/InfoDropDown/index.tsx new file mode 100644 index 000000000..e4ff6498b --- /dev/null +++ b/frontend/src/components/InfoDropDown/index.tsx @@ -0,0 +1,3 @@ +import InfoDropDown from '~/components/InfoDropDown/InfoDropDown'; + +export default InfoDropDown; diff --git a/frontend/src/components/LoginModalContent/LoginModalContent.stories.tsx b/frontend/src/components/LoginModalContent/LoginModalContent.stories.tsx new file mode 100644 index 000000000..48f398ca7 --- /dev/null +++ b/frontend/src/components/LoginModalContent/LoginModalContent.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import LoginModalContent from './LoginModalContent'; + +const meta: Meta = { + title: 'Modal/LoginModalContent', + component: LoginModalContent, + decorators: [ + Story => ( + + + } /> + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/frontend/src/components/LoginModalContent/LoginModalContent.tsx b/frontend/src/components/LoginModalContent/LoginModalContent.tsx new file mode 100644 index 000000000..a7c7eef30 --- /dev/null +++ b/frontend/src/components/LoginModalContent/LoginModalContent.tsx @@ -0,0 +1,20 @@ +import styled from 'styled-components'; +import LoginButton from '~/components/@common/LoginButton'; + +function LoginModalContent() { + return ( + + + + + + ); +} + +export default LoginModalContent; + +const StyledLoginModalContent = styled.div` + a + a { + margin-top: 1.6rem; + } +`; diff --git a/frontend/src/components/LoginModalContent/index.tsx b/frontend/src/components/LoginModalContent/index.tsx new file mode 100644 index 000000000..c632d277c --- /dev/null +++ b/frontend/src/components/LoginModalContent/index.tsx @@ -0,0 +1,3 @@ +import LoginModalContent from '~/components/LoginModalContent/LoginModalContent'; + +export default LoginModalContent; diff --git a/frontend/src/constants/api.ts b/frontend/src/constants/api.ts new file mode 100644 index 000000000..a4a3f9c22 --- /dev/null +++ b/frontend/src/constants/api.ts @@ -0,0 +1,13 @@ +export const BASE_URL = `${process.env.BASE_URL}`; + +export const OAUTH_LINK = { + google: `${BASE_URL}/api/oauth/google`, + kakao: `${BASE_URL}/api/oauth/kakao`, + naver: `${BASE_URL}/api/oauth/naver`, +}; + +export const OAUTH_BUTTON_MESSAGE = { + google: '구글로 로그인하기', + kakao: '카카오로 로그인하기', + naver: '네이버로 로그인하기', +}; diff --git a/frontend/src/pages/OauthRedirectPage.tsx b/frontend/src/pages/OauthRedirectPage.tsx new file mode 100644 index 000000000..75a2532e4 --- /dev/null +++ b/frontend/src/pages/OauthRedirectPage.tsx @@ -0,0 +1,44 @@ +import React, { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { BASE_URL } from '~/constants/api'; + +interface OauthRedirectProps { + type: 'google' | 'kakao' | 'naver'; +} + +function OauthRedirectPage({ type }: OauthRedirectProps) { + const location = useLocation(); + const navigate = useNavigate(); + + const handleOAuth = async (code: string) => { + try { + // 카카오로부터 받아온 code를 서버에 전달하여 카카오로 회원가입 & 로그인한다 + const response = await fetch(`${BASE_URL}/oauth/login/${type}?code=${code}`); + await response.json(); // 응답 데이터 + + alert('로그인 성공'); + + navigate('/success'); + } catch (error) { + navigate('/fail'); // 실패 페이지 + } + }; + + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const code = searchParams.get('code'); // 카카오는 Redirect 시키면서 code를 쿼리 스트링으로 준다. + + if (code) { + alert(`CODE = ${code}`); + handleOAuth(code); + } + }, [location]); + + return ( +
+
Processing...
+
+ ); +} + +export default OauthRedirectPage; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 728acf6a8..3f5ec7114 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2146,6 +2146,11 @@ schema-utils "^3.0.0" source-map "^0.7.3" +"@remix-run/router@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.2.tgz#cba1cf0a04bc04cb66027c51fa600e9cbc388bc8" + integrity sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A== + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -9672,6 +9677,21 @@ react-refresh@^0.11.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A== +react-router-dom@^6.14.2: + version "6.14.2" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.14.2.tgz#88f520118b91aa60233bd08dbd3fdcaea3a68488" + integrity sha512-5pWX0jdKR48XFZBuJqHosX3AAHjRAzygouMTyimnBPOLdY3WjzUSKhus2FVMihUFWzeLebDgr4r8UeQFAct7Bg== + dependencies: + "@remix-run/router" "1.7.2" + react-router "6.14.2" + +react-router@6.14.2: + version "6.14.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.2.tgz#1f60994d8c369de7b8ba7a78d8f7ec23df76b300" + integrity sha512-09Zss2dE2z+T1D03IheqAFtK4UzQyX8nFPWx6jkwdYzGLXd5ie06A6ezS2fO6zJfEb/SpG6UocN2O1hfD+2urQ== + dependencies: + "@remix-run/router" "1.7.2" + react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"