diff --git a/client/package.json b/client/package.json index 5fa1b3e..335ee00 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@tanstack/react-query": "^4.36.1", "@types/lodash": "^4.14.199", "axios": "^1.5.1", "jotai": "^2.4.3", diff --git a/client/src/@types/Auth.d.ts b/client/src/@types/Auth.d.ts new file mode 100644 index 0000000..a34ba2e --- /dev/null +++ b/client/src/@types/Auth.d.ts @@ -0,0 +1,63 @@ +declare module 'Auth' { + export type AuthResponse = { + success: boolean; + code: string; + message: string; + data: AuthData; + }; + + export type AuthData = { + availableHomepages: number[]; + isPrivacyPolicyAgree: boolean; + privacyPolicyAgreePeriod: number; + dept: Dept; + accessToken: string; + parentDept: UserDept; + branch: Branch; + showMobileMain: boolean; + memberNo: string; + alternativeId: string; + lastUpdated: string; + branchGroup: BranchGroup; + isPortalLogin: boolean; + patronType: PatronType; + disableServices: string[]; + hasFamily: boolean; + name: string; + printMemberNo: string; + patronState: PatronState; + id: number; + multiTypePatrons: any[]; + isExpired: boolean; + isFamilyLogin: boolean; + }; + + export type UserDept = { + id: number; + code: string; + name: string; + }; + + export type Branch = { + id: number; + name: string; + alias: string; + libraryCode: string; + sortOrder: number; + }; + + export type BranchGroup = { + id: number; + name: string; + }; + + export type PatronType = { + id: number; + name: string; + }; + + export type PatronState = { + id: number; + name: string; + }; +} diff --git a/client/src/@types/common.d.ts b/client/src/@types/common.d.ts new file mode 100644 index 0000000..3b39bbd --- /dev/null +++ b/client/src/@types/common.d.ts @@ -0,0 +1,41 @@ +declare module 'AxiosCommon' { + import { + AxiosInstance, + AxiosInterceptorManager, + AxiosRequestConfig, + AxiosResponse, + } from 'axios'; + + type CustomAxiosResponse = { + response?: T; + refreshToken?: string; + }; + + export interface CustomAxiosInterface extends AxiosInstance { + interceptors: { + request: AxiosInterceptorManager; + response: AxiosInterceptorManager>; + }; + + get(url: string, config?: AxiosRequestConfig): Promise; + delete(url: string, config?: AxiosRequestConfig): Promise; + post(url: string, data?: any, config?: AxiosRequestConfig): Promise; + put(url: string, data?: any, config?: AxiosRequestConfig): Promise; + patch(url: string, data?: any, config?: AxiosRequestConfig): Promise; + } + + export interface CommonRequest { + status: number; + message: string; + success?: boolean; + data?: any; + } + interface APIDataResponse { + data: T; + } + export interface CommonResponse { + data: APIDataResponse; + status: number; + statusText: string; + } +} diff --git a/client/src/apis/AxiosCreate.ts b/client/src/apis/AxiosCreate.ts new file mode 100644 index 0000000..d84a3dd --- /dev/null +++ b/client/src/apis/AxiosCreate.ts @@ -0,0 +1,87 @@ +import { getAccessToken, removeAccessToken } from '@/utils/lib/tokenHandler'; +import axios, { AxiosRequestConfig } from 'axios'; +import { CustomAxiosInterface, CommonResponse } from 'AxiosCommon'; + +const apiClient: CustomAxiosInterface = axios.create({ + headers: { + 'Content-Type': 'application/json', + }, + baseURL: `${process.env.NEXT_PUBLIC_BASE_URL}`, +}); + +// 요청 interceptor +apiClient.interceptors.request.use( + async (config: any) => { + const token = getAccessToken(); + config.headers = { + Accept: 'application/json, text/plain, */*', + 'pyxis-auth-token': `${token}`, + }; + return config; + }, + (error: unknown) => { + console.log(error); + return Promise.reject(error); + }, +); + +// 응답 interceptor +apiClient.interceptors.response.use( + async (config: any) => { + return config; + }, + async (error: any) => { + if (error.response.status === 400) { + removeAccessToken(); //만료시 토큰 제거 (업데이트가 없기 떄문) + } + return Promise.reject(error); + }, +); + +const Get = async (url: string, config?: AxiosRequestConfig): Promise => { + const response = await apiClient.get>(url, config); + return response.data.data; +}; + +const Post = async ( + url: string, + data?: any, + config?: AxiosRequestConfig, +): Promise => { + const response = await apiClient.post>(url, data, config); + return response.data.data; +}; + +const Put = async ( + url: string, + data?: any, + config?: AxiosRequestConfig, +): Promise => { + const response = await apiClient.put>(url, data, config); + return response.data.data; +}; + +const Patch = async ( + url: string, + data?: any, + config?: AxiosRequestConfig, +): Promise => { + const response = await apiClient.patch>(url, data, config); + return response.data.data; +}; + +const Delete = async ( + url: string, + config?: AxiosRequestConfig, +): Promise => { + const response = await apiClient.delete>(url, config); + return response.data.data; +}; + +export { + Get as get, + Post as post, + Put as put, + Patch as patch, + Delete as remove, +}; diff --git a/client/src/apis/auth.ts b/client/src/apis/auth.ts new file mode 100644 index 0000000..3fb8796 --- /dev/null +++ b/client/src/apis/auth.ts @@ -0,0 +1,23 @@ +import { AuthData, AuthResponse } from 'Auth'; +import axios, { AxiosResponse } from 'axios'; + +class AuthApi { + /** 로그인 */ + login = async (loginId: string, password: string): Promise => { + const data: AxiosResponse = await axios({ + method: 'post', + url: `${process.env.NEXT_PUBLIC_BASE_URL}/login`, + headers: { + 'Content-Type': 'application/json', + }, + data: { + loginId, + password, + }, + }); + + return data.data.data; + }; +} + +export default AuthApi; diff --git a/client/src/atoms/authInfoState.ts b/client/src/atoms/authInfoState.ts new file mode 100644 index 0000000..f6ccaf8 --- /dev/null +++ b/client/src/atoms/authInfoState.ts @@ -0,0 +1,8 @@ +import { atom } from 'jotai'; + +export type authInfoType = { + name: string; + sId: string; +}; + +export const authInfoState = atom(null); diff --git a/client/src/atoms/headerTitleState.ts b/client/src/atoms/headerTitleState.ts new file mode 100644 index 0000000..ccd9fa2 --- /dev/null +++ b/client/src/atoms/headerTitleState.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const headerTitleState = atom(''); diff --git a/client/src/atoms/index.tsx b/client/src/atoms/index.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/components/Buttons/Mini/index.tsx b/client/src/components/Buttons/Mini/index.tsx index 74ddb95..c6ba40e 100644 --- a/client/src/components/Buttons/Mini/index.tsx +++ b/client/src/components/Buttons/Mini/index.tsx @@ -54,7 +54,7 @@ const ButtonWrapper = styled.button<{ theme: Theme }>` border-radius: 5rem; border: none; outline: none; - ${TYPO.title3.Md}; + ${TYPO.text1.Md}; box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px; ${(props) => { diff --git a/client/src/components/Buttons/Round/index.tsx b/client/src/components/Buttons/Round/index.tsx index 7a07efd..5c22dc1 100644 --- a/client/src/components/Buttons/Round/index.tsx +++ b/client/src/components/Buttons/Round/index.tsx @@ -51,7 +51,7 @@ const ButtonWrapper = styled.button<{ theme: Theme }>` border-radius: 5rem; border: none; outline: none; - ${TYPO.title3.Sb}; + ${TYPO.text1.Sb}; box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px; ${flex('row', 'center', 'center', 0)}; diff --git a/client/src/components/Buttons/Square/index.tsx b/client/src/components/Buttons/Square/index.tsx index 727b7d0..4e763dd 100644 --- a/client/src/components/Buttons/Square/index.tsx +++ b/client/src/components/Buttons/Square/index.tsx @@ -51,7 +51,7 @@ const ButtonWrapper = styled.button<{ theme: Theme }>` border-radius: 1rem; border: none; outline: none; - ${TYPO.title3.Sb}; + ${TYPO.text1.Sb}; box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px; ${flex('row', 'center', 'center', 0)}; diff --git a/client/src/components/Field/TextInput/TextInput.stories.tsx b/client/src/components/Field/TextInput/TextInput.stories.tsx new file mode 100644 index 0000000..3e6d212 --- /dev/null +++ b/client/src/components/Field/TextInput/TextInput.stories.tsx @@ -0,0 +1,42 @@ +import type { StoryObj } from '@storybook/react'; +import TextInput from '.'; + +const meta = { + title: 'Field/TextInput', + component: TextInput, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + value: '', + placeholder: '값을 입력해주세요', + warning: false, + }, +}; + +export const Input: Story = { + args: { + value: '입력 값 입니다.', + warning: false, + }, +}; + +export const Warning: Story = { + args: { + value: '입력 값 입니다.', + warning: true, + }, +}; + +export const WarningCaption: Story = { + args: { + value: '입력 값 입니다.', + warning: true, + warningCaption: '경고 문구입니다.', + }, +}; diff --git a/client/src/components/Field/TextInput/index.tsx b/client/src/components/Field/TextInput/index.tsx new file mode 100644 index 0000000..d4da8ee --- /dev/null +++ b/client/src/components/Field/TextInput/index.tsx @@ -0,0 +1,116 @@ +import styled from '@emotion/styled'; +import { css } from '@emotion/react'; +import { ComponentProps } from 'react'; +import { COLORS } from '@/styles/colors'; +import { TYPO } from '@/styles/typo'; +import { injectAnimation } from '@/styles/animations'; + +interface Props extends ComponentProps<'input'> { + /** + * 입력 값 오버라이딩 + */ + value: string; + /** + * 경고 여부 (underline) + */ + warning?: boolean; + /** + * 경고 상황일 때, 하단 캡션 문구 + */ + warningCaption?: string; +} + +/** + * 커스텀 인풋 박스 + */ +const TextInput = ({ + value, + warning = false, + warningCaption, + ...props +}: Props) => { + return ( + + + + {warning && warningCaption && {warningCaption}} + + ); +}; + +const InputWrapper = styled.div` + width: 100%; + position: relative; +`; + +const CustomInput = styled.input` + width: 100%; + border: none; + outline: none; + background: none; + ${TYPO.title3.Lg}; + + &:focus + div::before { + width: 100%; + } + + &::placeholder { + ${TYPO.title3.Lg}; + color: ${COLORS.grey5}; + } +`; + +const Underline = styled.div` + position: absolute; + bottom: -0.6rem; + left: 0; + width: 100%; + height: 1px; + background-color: ${COLORS.grey5}; + + ::before, + ::after { + content: ''; + position: absolute; + height: 2px; + width: 0; + bottom: 0; + left: 0; + background-color: ${COLORS.primary}; + transition: width 0.4s; + } +`; + +const Caption = styled.span` + ${TYPO.caption.Reg}; + color: ${COLORS.tomato}; + text-align: start; + white-space: nowrap; + + position: absolute; + bottom: -2.8rem; + right: 0px; + + ${injectAnimation('fadeInTopDownTranslate', '0.2s')}; +`; + +const underlineStyle = { + filled: css` + &::before { + width: 100%; + } + `, + warning: css` + &::after { + background-color: ${COLORS.tomato}; + width: 100%; + } + `, +}; + +export default TextInput; diff --git a/client/src/components/Field/index.tsx b/client/src/components/Field/index.tsx new file mode 100644 index 0000000..137a144 --- /dev/null +++ b/client/src/components/Field/index.tsx @@ -0,0 +1 @@ +export { default as TextInput } from './TextInput'; diff --git a/client/src/components/Layouts/Frame/FrameHeader.tsx b/client/src/components/Layouts/Frame/FrameHeader.tsx new file mode 100644 index 0000000..79964fc --- /dev/null +++ b/client/src/components/Layouts/Frame/FrameHeader.tsx @@ -0,0 +1,30 @@ +import { useRouter } from 'next/router'; +import { NavHeader, TitleHeader } from '../Header'; +import { useHeader } from '@/hooks'; + +const FrameHeader = () => { + const router = useRouter(); + const { headerTitle } = useHeader(); + + const handleBack = () => { + router.back(); + }; + + const handleMyPage = () => { + router.push('/mypage'); + }; + + switch (router.pathname) { + case '/landing': + return <>; + case '/': + case '/template': + case '/mate': + case '/schedule': + return ; + default: + return ; + } +}; + +export default FrameHeader; diff --git a/client/src/components/Layouts/Frame/FrameNavigator.tsx b/client/src/components/Layouts/Frame/FrameNavigator.tsx new file mode 100644 index 0000000..a77ca30 --- /dev/null +++ b/client/src/components/Layouts/Frame/FrameNavigator.tsx @@ -0,0 +1,22 @@ +import { useRouter } from 'next/router'; +import { Navigator } from '..'; + +const FrameNavigator = () => { + const router = useRouter(); + + const handleRoute = (url: string) => { + router.push(url); + }; + + switch (router.pathname) { + case '/': + case '/template': + case '/mate': + case '/schedule': + return ; + default: + return <>; + } +}; + +export default FrameNavigator; diff --git a/client/src/components/Layouts/Frame/index.tsx b/client/src/components/Layouts/Frame/index.tsx new file mode 100644 index 0000000..b637ab6 --- /dev/null +++ b/client/src/components/Layouts/Frame/index.tsx @@ -0,0 +1,65 @@ +import { useVh } from '@/hooks'; +import { mq } from '@/styles/breakpoints'; +import { css } from '@emotion/react'; +import { type ComponentProps } from 'react'; +import FrameHeader from './FrameHeader'; +import styled from '@emotion/styled'; +import FrameNavigator from './FrameNavigator'; +import { COLORS } from '@/styles/colors'; +import { useRouter } from 'next/router'; + +interface FrameProps extends ComponentProps<'div'> { + children: React.ReactNode; +} + +/** 모바일 환경 최적화 프레임 */ +const Frame = ({ children, ...props }: FrameProps) => { + const { vh } = useVh(); + const router = useRouter(); + + const backgroundStyle = css` + max-width: 50rem; + width: 100vw; + min-height: calc(${vh}px * 100); + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + overflow: hidden; + + ${mq[3]} { + min-height: 100vh; + } + `; + + const getBgColor = (pathname: string) => { + switch (pathname) { + case '/landing': + return bgColorStyle.login; + default: + return css``; + } + }; + + return ( +
+ + {children} + +
+ ); +}; + +const Container = styled.div` + width: 100%; + height: 100%; + position: relative; +`; + +const bgColorStyle = { + login: css` + background-color: ${COLORS.primary}; + `, +}; +export default Frame; diff --git a/client/src/components/Layouts/Header/NavHeader.tsx b/client/src/components/Layouts/Header/NavHeader.tsx index 3bd5de4..cab7b8e 100644 --- a/client/src/components/Layouts/Header/NavHeader.tsx +++ b/client/src/components/Layouts/Header/NavHeader.tsx @@ -1,5 +1,6 @@ +import { mq } from '@/styles/breakpoints'; import { COLORS } from '@/styles/colors'; -import { flex } from '@/styles/tokens'; +import { flex, transform } from '@/styles/tokens'; import { TYPO } from '@/styles/typo'; import styled from '@emotion/styled'; import { faChevronLeft } from '@fortawesome/free-solid-svg-icons'; @@ -8,13 +9,14 @@ import { ComponentProps } from 'react'; interface Props extends ComponentProps<'div'> { title: string; + onBack?: () => void; } -const NavHeader = ({ title, ...props }: Props) => { +const NavHeader = ({ title, onBack, ...props }: Props) => { return ( - + {title} @@ -26,12 +28,17 @@ const NavHeader = ({ title, ...props }: Props) => { const HeaderWrapper = styled.div` width: 100%; min-width: 32rem; - height: 4.4rem; - - position: sticky; + height: 6rem; + position: fixed; top: 0px; + left: 50%; + ${transform('translate(-50%, 0%)')}; box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.1); + + ${mq[4]} { + height: 5rem; + } `; const HeaderInner = styled.div` @@ -48,6 +55,10 @@ const HeaderTitle = styled.span` `; const Chevron = styled.span` + height: 100%; + aspect-ratio: 1; + ${flex('row', 'center', 'center', 0)}; + ${TYPO.title2.Md}; color: ${COLORS.grey0}; position: absolute; diff --git a/client/src/components/Layouts/Header/TitleHeader.tsx b/client/src/components/Layouts/Header/TitleHeader.tsx index 78c0d9d..18815b3 100644 --- a/client/src/components/Layouts/Header/TitleHeader.tsx +++ b/client/src/components/Layouts/Header/TitleHeader.tsx @@ -1,15 +1,20 @@ import styled from '@emotion/styled'; import { TYPO } from '@/styles/typo'; import { COLORS } from '@/styles/colors'; -import { flex } from '@/styles/tokens'; +import { flex, transform } from '@/styles/tokens'; import HumanIcon from '@/assets/svg/human.svg'; import { css } from '@emotion/react'; +import { mq } from '@/styles/breakpoints'; -const TitleHeader = () => { +interface Props { + mypageHandler?: () => void; +} + +const TitleHeader = ({ mypageHandler }: Props) => { return ( SSUDOBI - + ); }; @@ -19,25 +24,30 @@ const hovering = css` transition: all 0.2s; &:hover { - transform: scale(1.02) translateY(-2px); + transform: scale(1.02) translateY(-1px); } &:active { - transform: scale(0.99) translateY(1px); + transform: scale(0.99) translateY(0.5px); } `; const HeaderWrapper = styled.div` width: 100%; min-width: 32rem; - height: 4.4rem; + height: 6rem; ${flex('row', 'between', 'center', 0)}; - - position: sticky; + position: fixed; top: 0px; + left: 50%; + ${transform('translate(-50%, 0%)')}; box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.1); padding: 0rem 2.7rem; + + ${mq[4]} { + height: 5rem; + } `; const Logo = styled.span` diff --git a/client/src/components/Layouts/Navigator/index.tsx b/client/src/components/Layouts/Navigator/index.tsx index 460d06f..46af120 100644 --- a/client/src/components/Layouts/Navigator/index.tsx +++ b/client/src/components/Layouts/Navigator/index.tsx @@ -1,5 +1,5 @@ import { COLORS } from '@/styles/colors'; -import { flex } from '@/styles/tokens'; +import { flex, transform } from '@/styles/tokens'; import { TYPO } from '@/styles/typo'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; @@ -32,23 +32,27 @@ const Navigator = ({ curRoute, handleRoute }: Props) => { { icon: , title: '템플릿', - url: '/templates', + url: '/template', }, { icon: , title: '메이트', - url: '/mates', + url: '/mate', }, { icon: , title: '스케줄', - url: '/schedules', + url: '/schedule', }, ]; return ( - {items.map((item, idx) => ( - + {items.map((item) => ( + handleRoute(item.url)} + key={item.title} + > {item.icon} {item.title} @@ -59,13 +63,15 @@ const Navigator = ({ curRoute, handleRoute }: Props) => { const NavigatorWrapper = styled.div` width: 100%; - min-width: 32rem; + min-width: 25rem; height: 7rem; ${flex('row', 'center', 'center', 0)}; position: fixed; bottom: 0px; - left: 0px; + left: 50%; + + ${transform('translate(-50%, 0)')} border-top: 0.5px solid rgba(10, 10, 10, 0.1); `; diff --git a/client/src/components/Layouts/Title/index.tsx b/client/src/components/Layouts/Title/index.tsx index 60f752a..82c33e0 100644 --- a/client/src/components/Layouts/Title/index.tsx +++ b/client/src/components/Layouts/Title/index.tsx @@ -37,6 +37,7 @@ const TitleWrapper = styled.div` ${flex('column', 'start', 'start', 1.1)}; white-space: pre-line; word-break: keep-all; + position: relative; `; const typo = { diff --git a/client/src/components/Layouts/index.tsx b/client/src/components/Layouts/index.tsx index a8a73dc..f97fcf7 100644 --- a/client/src/components/Layouts/index.tsx +++ b/client/src/components/Layouts/index.tsx @@ -1,3 +1,4 @@ export { default as Navigator } from './Navigator'; export { default as Picker } from './Picker'; export { default as Title } from './Title'; +export { default as Frame } from './Frame'; diff --git a/client/src/components/Login/Circle/index.tsx b/client/src/components/Login/Circle/index.tsx new file mode 100644 index 0000000..22f87df --- /dev/null +++ b/client/src/components/Login/Circle/index.tsx @@ -0,0 +1,15 @@ +import styled from '@emotion/styled'; +import { ComponentProps } from 'react'; + +const Circle = (props: ComponentProps<'div'>) => { + return ; +}; + +const Container = styled.div` + width: 48rem; + height: 48rem; + border-radius: 50rem; + background: linear-gradient(180deg, rgba(29, 155, 240, 0) 0%, #aeddfd 100%); +`; + +export default Circle; diff --git a/client/src/components/Login/index.tsx b/client/src/components/Login/index.tsx new file mode 100644 index 0000000..8713c22 --- /dev/null +++ b/client/src/components/Login/index.tsx @@ -0,0 +1 @@ +export { default as Circle } from './Circle'; diff --git a/client/src/constants/queryKey.ts b/client/src/constants/queryKey.ts new file mode 100644 index 0000000..0e70367 --- /dev/null +++ b/client/src/constants/queryKey.ts @@ -0,0 +1,3 @@ +export const QUERY_KEYS = { + login: 'LOGIN', +}; diff --git a/client/src/hooks/index.tsx b/client/src/hooks/index.tsx index e69de29..f6a181b 100644 --- a/client/src/hooks/index.tsx +++ b/client/src/hooks/index.tsx @@ -0,0 +1,4 @@ +export { default as useVh } from './useVh'; +export { default as useHeader } from './useHeader'; +export { default as useInput } from './useInput'; +export { default as useAuth } from './useAuth'; diff --git a/client/src/hooks/useAuth.tsx b/client/src/hooks/useAuth.tsx new file mode 100644 index 0000000..2361d2b --- /dev/null +++ b/client/src/hooks/useAuth.tsx @@ -0,0 +1,62 @@ +import AuthApi from '@/apis/auth'; +import { authInfoState } from '@/atoms/authInfoState'; +import { getUserInfo, updateUserInfo } from '@/utils/lib/infoHandler'; +import { updateAccessToken } from '@/utils/lib/tokenHandler'; +import { useAtom } from 'jotai'; +import { useRouter } from 'next/router'; + +const useAuth = () => { + const authApi = new AuthApi(); + + const router = useRouter(); + const [authInfo, setAuthInfo] = useAtom(authInfoState); + + /** + * 로그인 함수 + */ + const handleLogin = async (id: string, password: string) => { + try { + const data = await authApi.login(id, password); + console.log(data); + setAuthInfo({ + name: data.name, + sId: data.printMemberNo, + }); + updateAccessToken(data.accessToken); + updateUserInfo(data.name, data.printMemberNo, id, password); + router.replace('/'); + } catch (err) { + console.log(err); + } + }; + + /** + * 자동 로그인 함수 + */ + const autoLogin = async () => { + try { + const userInfo = getUserInfo(); + if (!userInfo) throw new Error('Empty user info'); + const data = await authApi.login(userInfo.loginId, userInfo.password); + console.log(data); + setAuthInfo({ + name: data.name, + sId: data.printMemberNo, + }); + updateAccessToken(data.accessToken); + updateUserInfo( + data.name, + data.printMemberNo, + userInfo.loginId, + userInfo.password, + ); + } catch (err) { + console.log(err); + router.replace('/landing'); + } + }; + + return { authInfo, autoLogin, handleLogin }; +}; + +export default useAuth; diff --git a/client/src/hooks/useHeader.tsx b/client/src/hooks/useHeader.tsx new file mode 100644 index 0000000..e7f1d0c --- /dev/null +++ b/client/src/hooks/useHeader.tsx @@ -0,0 +1,14 @@ +import { headerTitleState } from '@/atoms/headerTitleState'; +import { useAtom } from 'jotai'; + +const useHeader = () => { + const [headerTitle, setHeaderTitle] = useAtom(headerTitleState); + + const setHeader = (title: string) => { + setHeaderTitle(title); + }; + + return { headerTitle, setHeader }; +}; + +export default useHeader; diff --git a/client/src/hooks/useInput.tsx b/client/src/hooks/useInput.tsx new file mode 100644 index 0000000..bc72585 --- /dev/null +++ b/client/src/hooks/useInput.tsx @@ -0,0 +1,33 @@ +import { ChangeEvent, useState } from 'react'; +import { set } from 'lodash'; + +type UseInputReturnType = { + values: T; + handleChange: (event: ChangeEvent) => void; + setValue: (path: K, value: T[K]) => void; +}; + +const useInput = ( + initialValues: T, +): UseInputReturnType => { + const [values, setValues] = useState(initialValues); + + const handleChange = (event: ChangeEvent): void => { + const { name, value } = event.target; + const updatedValues = set({ ...values }, name, value); + setValues(updatedValues as T); + }; + + const setValue = (path: K, value: T[K]): void => { + const updatedValues = set({ ...values }, path as string, value); + setValues(updatedValues as T); + }; + + return { + values, + handleChange, + setValue, + }; +}; + +export default useInput; diff --git a/client/src/hooks/useVh.tsx b/client/src/hooks/useVh.tsx new file mode 100644 index 0000000..6bd0728 --- /dev/null +++ b/client/src/hooks/useVh.tsx @@ -0,0 +1,32 @@ +import { css } from '@emotion/react'; +import { useEffect, useMemo, useState } from 'react'; + +/** 리사이징 이벤트에 따라 변하는 vh 가져오는 훅 (불필요한 스크롤 생기는 이슈 방지) */ +const useVh = () => { + const [vh, setVh] = useState(0); + + const mobileScreenSize = () => { + let vh = window.innerHeight * 0.01; + setVh(vh); + }; + + const fullPageStyle = useMemo( + () => css` + width: 100%; + height: calc(${vh}px * 100); + `, + [vh], + ); + + useEffect(() => { + mobileScreenSize(); + window.addEventListener('resize', () => mobileScreenSize()); + return () => { + window.removeEventListener('resize', mobileScreenSize); + }; + }, []); + + return { vh, fullPageStyle }; +}; + +export default useVh; diff --git a/client/src/pages/_app.tsx b/client/src/pages/_app.tsx index a2776de..ce88842 100644 --- a/client/src/pages/_app.tsx +++ b/client/src/pages/_app.tsx @@ -1,13 +1,52 @@ import type { AppProps } from 'next/app'; -import { Global } from '@emotion/react'; import '@fortawesome/fontawesome-svg-core/styles.css'; +import { Frame } from '@/components/Layouts'; +import useAuth from '@/hooks/useAuth'; +import { useEffect } from 'react'; +import { Global, css } from '@emotion/react'; import reset from '@/styles/reset'; +import { useRouter } from 'next/router'; +import { COLORS } from '@/styles/colors'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); export default function App({ Component, pageProps }: AppProps) { + const { autoLogin } = useAuth(); + const router = useRouter(); + + const getBgColor = (pathname: string) => { + switch (pathname) { + case '/landing': + return bgColorStyle.login; + default: + return bgColorStyle.default; + } + }; + + useEffect(() => { + autoLogin(); + }, []); + return ( - <> - - - + + + + + + ); } + +const bgColorStyle = { + login: css` + html { + background-color: ${COLORS.primary}; + } + `, + default: css` + html { + background-color: ${COLORS.white}; + } + `, +}; diff --git a/client/src/pages/create/reserve.tsx b/client/src/pages/create/reserve.tsx new file mode 100644 index 0000000..d9557d9 --- /dev/null +++ b/client/src/pages/create/reserve.tsx @@ -0,0 +1,18 @@ +import { useHeader } from '@/hooks'; +import { PageContainer } from '@/styles/tokens'; +import { useLayoutEffect } from 'react'; + +/** + * 예약하기 페이지 + */ +const Reserve = () => { + const { setHeader } = useHeader(); + + useLayoutEffect(() => { + setHeader('예약하기'); + }, []); + + return ; +}; + +export default Reserve; diff --git a/client/src/pages/create/template.tsx b/client/src/pages/create/template.tsx new file mode 100644 index 0000000..0b48331 --- /dev/null +++ b/client/src/pages/create/template.tsx @@ -0,0 +1,18 @@ +import { useHeader } from '@/hooks'; +import { PageContainer } from '@/styles/tokens'; +import { useLayoutEffect } from 'react'; + +/** + * 템플릿 추가하기 페이지 + */ +const Template = () => { + const { setHeader } = useHeader(); + + useLayoutEffect(() => { + setHeader('템플릿 추가하기'); + }, []); + + return ; +}; + +export default Template; diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index 7bcd29e..5b19e64 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -1,3 +1,5 @@ -export default function Home() { - return
; -} +const Home = () => { + return <>; +}; + +export default Home; diff --git a/client/src/pages/landing.tsx b/client/src/pages/landing.tsx new file mode 100644 index 0000000..afb208a --- /dev/null +++ b/client/src/pages/landing.tsx @@ -0,0 +1,100 @@ +import { RoundButton } from '@/components/Buttons'; +import { Circle } from '@/components/Login'; +import { useVh } from '@/hooks'; +import { injectAnimation } from '@/styles/animations'; +import { COLORS } from '@/styles/colors'; +import { flex, transform } from '@/styles/tokens'; +import { TYPO } from '@/styles/typo'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { useRouter } from 'next/router'; + +/** + * 시작 페이지 + */ +const Landing = () => { + const { fullPageStyle } = useVh(); + const route = useRouter(); + + const handleRouteLogin = () => { + route.push('login'); + }; + + return ( + + + SSUDOBI + 숭실대학교 도서관 비대면 예약 시스템 + + + + {`로그인 시, 개인 정보 처리 방침 및\n서비스 이용 약관에 동의하게 됩니다.`} + + + + + ); +}; + +const Container = styled.div` + background-color: ${COLORS.primary}; + position: relative; +`; + +const TitleWrapper = styled.div` + ${flex('column', 'center', 'center', 1.4)}; + cursor: default; + + color: white; + text-align: center; + white-space: nowrap; + + position: absolute; + top: 50%; + left: 50%; + ${transform('translate(-50%, -50%)')}; + + ${injectAnimation('loginTitlePopup', '3s', 'ease', '0s')} +`; + +const ButtonWrapper = styled.div` + width: 80%; + max-width: 40rem; + ${flex('column', 'center', 'center', 2.1)}; + + position: absolute; + left: 50%; + + ${transform('translate(-50%, -50%)')}; + ${injectAnimation('loginButtonPopup', '3.3s', 'ease', '0s')}; +`; + +const Caption = styled.span` + ${TYPO.text2.Reg}; + white-space: pre-line; + text-align: center; + line-height: 150%; + color: white; +`; + +const circleStyles = { + common: css` + position: absolute; + `, + top: css` + top: -30%; + right: -40%; + ${injectAnimation('circleMovingTop', '3s', 'ease', '0s')} + `, + bottom: css` + bottom: -30%; + left: -40%; + ${injectAnimation('circleMovingBottom', '3s', 'ease', '0s')} + `, +}; + +export default Landing; diff --git a/client/src/pages/login.tsx b/client/src/pages/login.tsx new file mode 100644 index 0000000..687ef06 --- /dev/null +++ b/client/src/pages/login.tsx @@ -0,0 +1,96 @@ +import { RoundButton } from '@/components/Buttons'; +import { TextInput } from '@/components/Field'; +import { useAuth, useHeader, useInput, useVh } from '@/hooks'; +import { COLORS } from '@/styles/colors'; +import { flex } from '@/styles/tokens'; +import { TYPO } from '@/styles/typo'; +import styled from '@emotion/styled'; +import { useLayoutEffect } from 'react'; + +type FormData = { + id: string; + password: string; +}; + +/** + * 로그인 페이지 + */ +const Login = () => { + const { setHeader } = useHeader(); + const { fullPageStyle } = useVh(); + const { handleLogin } = useAuth(); + const { values, handleChange } = useInput({ id: '', password: '' }); + + useLayoutEffect(() => { + setHeader('로그인'); + }, []); + + return ( + + + + 학번 + + + + 비밀번호 + + + + + handleLogin(values.id, values.password)} + /> + 비밀번호를 재설정하고 싶어요. + + + ); +}; + +const Container = styled.div` + width: 100%; + padding: 12rem 4.5rem; + ${flex('column', 'between', 'center', 5)}; +`; + +const InputWrapper = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 5)}; +`; + +const InputBox = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 1)}; +`; + +const Caption = styled.span` + ${TYPO.title3.Reg}; + color: ${COLORS.grey0}; +`; + +const ButtonWrapper = styled.div` + width: 100%; + ${flex('column', 'start', 'center', 3)}; +`; + +const Link = styled.span` + ${TYPO.text2.Reg}; + color: ${COLORS.grey3}; + cursor: pointer; + text-decoration: underline; +`; + +export default Login; diff --git a/client/src/pages/mate.tsx b/client/src/pages/mate.tsx new file mode 100644 index 0000000..8faf483 --- /dev/null +++ b/client/src/pages/mate.tsx @@ -0,0 +1,5 @@ +const Mate = () => { + return <>; +}; + +export default Mate; diff --git a/client/src/pages/mypage.tsx b/client/src/pages/mypage.tsx new file mode 100644 index 0000000..b02c7f6 --- /dev/null +++ b/client/src/pages/mypage.tsx @@ -0,0 +1,18 @@ +import { useHeader } from '@/hooks'; +import { PageContainer } from '@/styles/tokens'; +import { useLayoutEffect } from 'react'; + +/** + * 마이페이지 + */ +const Mypage = () => { + const { setHeader } = useHeader(); + + useLayoutEffect(() => { + setHeader('마이페이지'); + }, []); + + return ; +}; + +export default Mypage; diff --git a/client/src/pages/schedule.tsx b/client/src/pages/schedule.tsx new file mode 100644 index 0000000..6ba8391 --- /dev/null +++ b/client/src/pages/schedule.tsx @@ -0,0 +1,5 @@ +const Schedule = () => { + return <>; +}; + +export default Schedule; diff --git a/client/src/pages/template.tsx b/client/src/pages/template.tsx new file mode 100644 index 0000000..068f1a8 --- /dev/null +++ b/client/src/pages/template.tsx @@ -0,0 +1,5 @@ +const Template = () => { + return <>; +}; + +export default Template; diff --git a/client/src/styles/animations.tsx b/client/src/styles/animations.tsx index e9d82a3..60e64c4 100644 --- a/client/src/styles/animations.tsx +++ b/client/src/styles/animations.tsx @@ -14,6 +14,17 @@ const fadeInTopDown = keyframes` } `; +const fadeInTopDownTranslate = keyframes` + from{ + opacity: 0; + ${transform('translate(0px, -10px)')} + } + to{ + opacity: 1; + ${transform('translate(0px, 0px)')} + } +`; + const popUp = keyframes` 0%{ opacity: 0; @@ -29,9 +40,74 @@ const popUp = keyframes` } `; +const circleMoving = { + top: keyframes` + 0%{ + opacity: 0; + ${transform('rotate(180deg) translate(10%, -10%)')}; + } + 50%{ + opacity: 1; + ${transform('rotate(180deg) translate(0%, -10%)')}; + } + 100%{ + opacity: 1; + ${transform('rotate(180deg) translate(0%, 0%)')}; + } + `, + bottom: keyframes` + 0%{ + opacity: 0; + ${transform('rotate(10deg) translate(10%, -10%)')}; + } + 50%{ + opacity: 1; + ${transform('rotate(10deg) translate(0%, -10%)')}; + } + 100%{ + opacity: 1; + ${transform('rotate(10deg) translate(0%, 0%)')}; + } + `, +}; + +const loginTitlePopup = keyframes` + 0%{ + opacity: 0; + top: 50%; + } + 50%{ + opacity: 1; + top: 50%; + } + 100%{ + opacity: 1; + top: 35%; + } +`; + +const loginButtonPopup = keyframes` + 0%{ + opacity: 0; + } + 50%{ + opacity: 0; + top: 55%; + } + 100%{ + opacity: 1; + top: 65%; + } +`; + const animations = { fadeInTopDown, + fadeInTopDownTranslate, popUp, + circleMovingTop: circleMoving.top, + circleMovingBottom: circleMoving.bottom, + loginTitlePopup, + loginButtonPopup, }; export const injectAnimation = ( @@ -39,12 +115,14 @@ export const injectAnimation = ( duration = '1.5s', type = 'linear', delay = '0s', - afterStyle?: SerializedStyles, + relative = false, ): SerializedStyles => { const newAnimation = css` - position: relative; animation: ${animations[animation]} ${duration} ${type} ${delay} forwards; - ${afterStyle} + ${relative && + css` + position: relative; + `}; `; return newAnimation; diff --git a/client/src/styles/reset.tsx b/client/src/styles/reset.tsx index f177429..7c449fb 100644 --- a/client/src/styles/reset.tsx +++ b/client/src/styles/reset.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/react'; import { mq } from './breakpoints'; import { COLORS } from './colors'; +import { transition } from './tokens'; const reset = css` html, @@ -14,6 +15,8 @@ const reset = css` background-color: ${COLORS.white}; font-size: 9px; + ${transition('0.5s', 'linear')}; + ${mq[2]} { font-size: 9px; } diff --git a/client/src/styles/tokens.tsx b/client/src/styles/tokens.tsx index 9ef7a51..5a150e0 100644 --- a/client/src/styles/tokens.tsx +++ b/client/src/styles/tokens.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/react'; +import styled from '@emotion/styled'; type Direction = 'row' | 'column'; type JustifyContent = @@ -64,3 +65,8 @@ export const transition = (duration: string, animationType = 'linear') => { transition: all ${duration}; `; }; + +export const PageContainer = styled.div` + width: 100%; + padding: 1rem 2.7rem; +`; diff --git a/client/src/styles/typo/index.tsx b/client/src/styles/typo/index.tsx index c5757b7..ca6551d 100644 --- a/client/src/styles/typo/index.tsx +++ b/client/src/styles/typo/index.tsx @@ -43,115 +43,115 @@ export const TYPO: TypoType = { [TEXT_STYLE_SIZE.title1]: { [TEXT_STYLE_WEIGHT.Eb]: css` ${pretendardExtrabold.style}; - font-size: 4.8rem; + font-size: 5rem; `, [TEXT_STYLE_WEIGHT.Sb]: css` ${pretendardSemibold.style}; - font-size: 2rem; + font-size: 2.2rem; `, }, [TEXT_STYLE_SIZE.title2]: { [TEXT_STYLE_WEIGHT.Bd]: css` ${pretendardBold.style}; - font-size: 1.6rem; + font-size: 1.8rem; `, [TEXT_STYLE_WEIGHT.Sb]: css` ${pretendardSemibold.style}; - font-size: 1.6rem; + font-size: 1.8rem; `, [TEXT_STYLE_WEIGHT.Md]: css` ${pretendardMedium.style}; - font-size: 1.6rem; + font-size: 1.8rem; `, [TEXT_STYLE_WEIGHT.Reg]: css` - ${pretendardSemibold.style}; - font-size: 1.6rem; + ${pretendardRegular.style}; + font-size: 1.8rem; `, /** 얘는 조금 특별하게 굵기 차이가 아닌 간격차이로 인지해주세용 */ [TEXT_STYLE_WEIGHT.Lg]: css` ${pretendardSemibold.style}; - font-size: 1.6rem; + font-size: 1.8rem; `, }, [TEXT_STYLE_SIZE.title3]: { [TEXT_STYLE_WEIGHT.Sb]: css` ${pretendardSemibold.style}; - font-size: 1.5rem; + font-size: 1.7rem; `, [TEXT_STYLE_WEIGHT.Md]: css` ${pretendardMedium.style}; - font-size: 1.5rem; + font-size: 1.7rem; `, [TEXT_STYLE_WEIGHT.Reg]: css` ${pretendardRegular.style}; - font-size: 1.5rem; + font-size: 1.7rem; `, }, [TEXT_STYLE_SIZE.text1]: { [TEXT_STYLE_WEIGHT.Sb]: css` ${pretendardSemibold.style}; - font-size: 1.4rem; + font-size: 1.6rem; `, [TEXT_STYLE_WEIGHT.Md]: css` ${pretendardMedium.style}; - font-size: 1.4rem; + font-size: 1.6rem; `, [TEXT_STYLE_WEIGHT.Reg]: css` ${pretendardRegular.style}; - font-size: 1.4rem; + font-size: 1.6rem; `, /** 얘는 조금 특별하게 굵기 차이가 아닌 간격차이로 인지해주세용 */ [TEXT_STYLE_WEIGHT.Lg]: css` ${pretendardRegular.style}; - font-size: 1.4rem; + font-size: 1.6rem; `, }, [TEXT_STYLE_SIZE.text2]: { [TEXT_STYLE_WEIGHT.Sb]: css` ${pretendardSemibold.style}; - font-size: 1.2rem; + font-size: 1.4rem; `, [TEXT_STYLE_WEIGHT.Md]: css` ${pretendardMedium.style}; - font-size: 1.2rem; + font-size: 1.4rem; `, [TEXT_STYLE_WEIGHT.Reg]: css` ${pretendardRegular.style}; - font-size: 1.2rem; + font-size: 1.4rem; `, }, [TEXT_STYLE_SIZE.text3]: { [TEXT_STYLE_WEIGHT.Eb]: css` ${pretendardExtrabold.style}; - font-size: 1rem; + font-size: 1.2rem; `, [TEXT_STYLE_WEIGHT.Reg]: css` ${pretendardRegular.style}; - font-size: 1rem; + font-size: 1.2rem; `, [TEXT_STYLE_WEIGHT.Lg]: css` ${pretendardLight.style}; - font-size: 1rem; + font-size: 1.2rem; `, }, [TEXT_STYLE_SIZE.caption]: { [TEXT_STYLE_WEIGHT.Md]: css` ${pretendardMedium.style}; - font-size: 2.2rem; + font-size: 2.4rem; `, [TEXT_STYLE_WEIGHT.Reg]: css` ${pretendardRegular.style}; - font-size: 1.3rem; + font-size: 1.5rem; `, }, [TEXT_STYLE_SIZE.label]: { [TEXT_STYLE_WEIGHT.Md]: css` ${pretendardMedium.style}; - font-size: 0.8rem; + font-size: 1rem; `, [TEXT_STYLE_WEIGHT.Reg]: css` ${pretendardRegular.style}; - font-size: 0.8rem; + font-size: 1rem; `, }, }; diff --git a/client/src/utils/lib/infoHandler.ts b/client/src/utils/lib/infoHandler.ts new file mode 100644 index 0000000..ae62306 --- /dev/null +++ b/client/src/utils/lib/infoHandler.ts @@ -0,0 +1,39 @@ +export type StudentInfo = { + name: string; + sId: string; + loginId: string; + password: string; +}; + +/** + * 정보 가져오기 + */ +export const getUserInfo = (): StudentInfo | null => { + const data = localStorage.getItem('USER_INFO'); + return data ? JSON.parse(data) : null; +}; + +/** + * 정보 업데이트 + */ +export const updateUserInfo = ( + name: string, + sId: string, + loginId: string, + password: string, +) => { + const data = { + name, + sId, + loginId, + password, + }; + localStorage.setItem('USER_INFO', JSON.stringify(data)); +}; + +/** + * 정보 삭제 (로그아웃) + */ +export const removeUserInfo = () => { + localStorage.removeItem('USER_INFO'); +}; diff --git a/client/src/utils/lib/tokenHandler.ts b/client/src/utils/lib/tokenHandler.ts new file mode 100644 index 0000000..c26034d --- /dev/null +++ b/client/src/utils/lib/tokenHandler.ts @@ -0,0 +1,20 @@ +/** + * 액세스 토큰 가져오기 + */ +export const getAccessToken = () => { + return localStorage.getItem('ACESS_TOKEN'); +}; + +/** + * 액세스 토큰 업데이트 + */ +export const updateAccessToken = (token: string) => { + localStorage.setItem('ACCESS_TOKEN', token); +}; + +/** + * 토큰 삭제 (로그아웃) + */ +export const removeAccessToken = () => { + localStorage.removeItem('ACESS_TOKEN'); +};