├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── question.yml │ ├── feature.yml │ └── bug.yml └── PULL_REQUEST_TEMPLATE.md ├── public ├── logo.png ├── robots.txt ├── manifest.json └── index.html ├── src ├── types │ ├── progressBar.type.ts │ ├── auth.type.ts │ ├── common │ │ ├── button.type.ts │ │ ├── input.type.ts │ │ └── modal.type.ts │ ├── user.type.ts │ └── petition.type.ts ├── assets │ ├── banner.png │ ├── background.png │ ├── logo.svg │ ├── loginProfile.svg │ ├── profile.svg │ ├── search.svg │ ├── add.svg │ ├── check.svg │ └── google.svg ├── constants │ ├── token.constant.ts │ └── key.constant.ts ├── utils │ ├── EmailReplace.tsx │ ├── FormatDatetime.tsx │ ├── ScrollTop.tsx │ ├── GlobalModal.tsx │ └── ProgressChecker.tsx ├── atoms │ ├── modal.ts │ └── user.ts ├── pages │ ├── Login │ │ ├── Council │ │ │ ├── style.ts │ │ │ └── index.tsx │ │ ├── style.ts │ │ └── index.tsx │ ├── Callback │ │ ├── style.ts │ │ └── index.tsx │ ├── Main │ │ ├── Banner │ │ │ ├── index.tsx │ │ │ └── style.ts │ │ ├── style.ts │ │ └── index.tsx │ ├── NotFound │ │ └── index.tsx │ ├── MyPetition │ │ ├── style.ts │ │ └── index.tsx │ ├── WritePetition │ │ ├── style.ts │ │ ├── CheckWriteModal │ │ │ ├── style.ts │ │ │ └── index.tsx │ │ └── index.tsx │ └── PetitionDetail │ │ ├── Comment │ │ ├── style.ts │ │ └── index.tsx │ │ ├── Answer │ │ ├── index.tsx │ │ └── style.ts │ │ ├── style.ts │ │ └── index.tsx ├── components │ ├── common │ │ ├── Layout │ │ │ └── index.tsx │ │ ├── Loading │ │ │ └── index.tsx │ │ ├── Input │ │ │ ├── style.ts │ │ │ └── index.tsx │ │ ├── MiniButton │ │ │ ├── index.tsx │ │ │ └── style.ts │ │ ├── Button │ │ │ ├── index.tsx │ │ │ └── style.ts │ │ ├── SearchInput │ │ │ ├── index.tsx │ │ │ └── style.ts │ │ ├── Header │ │ │ ├── ProfilePopover │ │ │ │ ├── style.ts │ │ │ │ └── index.tsx │ │ │ ├── style.ts │ │ │ └── index.tsx │ │ └── Confirm │ │ │ ├── style.ts │ │ │ └── index.tsx │ ├── Ment │ │ ├── index.tsx │ │ └── style.ts │ ├── Progressbar │ │ ├── style.ts │ │ └── index.tsx │ ├── RadioTabMenu │ │ ├── style.ts │ │ ├── data.ts │ │ └── index.tsx │ └── PetitionList │ │ ├── style.ts │ │ └── index.tsx ├── libs │ ├── token │ │ ├── authorization.ts │ │ └── tokenExpired.ts │ ├── storage │ │ └── storage.ts │ └── axios │ │ └── customAxios.ts ├── hooks │ ├── useModal.ts │ ├── useTokenCheck.ts │ └── useUser.ts ├── styles │ ├── theme.style.ts │ ├── global.style.ts │ └── text.style.ts ├── features │ ├── LogoutFeature.ts │ ├── WritePetitionFeature.ts │ ├── LoginFeature.ts │ ├── PetitionListFeature.ts │ ├── GoogleLoginFeature.ts │ ├── MyPetitionFeature.ts │ └── PetitionFeature.ts ├── api │ ├── auth.api.ts │ ├── user.api.ts │ └── petition.api.ts ├── fixtures │ └── index.ts ├── App.tsx └── index.tsx ├── @types └── module.d.ts ├── README.md ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── LICENSE ├── .eslintrc.js └── package.json /.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_BASE_URL=string -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bamdoliro/stupetition/HEAD/public/logo.png -------------------------------------------------------------------------------- /src/types/progressBar.type.ts: -------------------------------------------------------------------------------- 1 | export type ProgressBarOption = 'LIST' | 'DETAIL'; 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bamdoliro/stupetition/HEAD/src/assets/banner.png -------------------------------------------------------------------------------- /src/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bamdoliro/stupetition/HEAD/src/assets/background.png -------------------------------------------------------------------------------- /src/types/auth.type.ts: -------------------------------------------------------------------------------- 1 | export interface LoginType { 2 | username: string; 3 | password: string; 4 | } 5 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 📄 Summary 2 | 3 | > 4 | 5 |
6 | 7 | ## 🔨 Tasks 8 | 9 | - 10 | 11 |
12 | 13 | ## 🙋🏻 More 14 | -------------------------------------------------------------------------------- /@types/module.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; 2 | declare module '*.jpg'; 3 | declare module '*.jpeg'; 4 | declare module '*.svg'; 5 | declare module '*.gif'; 6 | -------------------------------------------------------------------------------- /src/constants/token.constant.ts: -------------------------------------------------------------------------------- 1 | export const ACCESS_KEY = 'access-token'; 2 | export const REFRESH_KEY = 'refresh-token'; 3 | export const REQUEST_KEY = 'Authorization'; 4 | -------------------------------------------------------------------------------- /src/types/common/button.type.ts: -------------------------------------------------------------------------------- 1 | export type ButtonOptionType = 'FILLED' | 'UNFILLED'; 2 | 3 | export type MiniButtonOptionType = 'FILLED' | 'UNFILLED' | 'SCARCE_FILLED'; 4 | -------------------------------------------------------------------------------- /src/utils/EmailReplace.tsx: -------------------------------------------------------------------------------- 1 | export const EmailReplace = (email: string) => { 2 | const userEmail = email.replace('@bssm.hs.kr', ''); 3 | 4 | return { userEmail }; 5 | }; 6 | -------------------------------------------------------------------------------- /src/atoms/modal.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { atom } from 'recoil'; 3 | 4 | export const modalState = atom({ 5 | key: 'modalState', 6 | default: null, 7 | }); 8 | -------------------------------------------------------------------------------- /src/utils/FormatDatetime.tsx: -------------------------------------------------------------------------------- 1 | export const FormatDatetime = (data: string) => { 2 | const datetime = data.split('T'); 3 | return { 4 | date: datetime[0], 5 | time: datetime[1], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /src/types/user.type.ts: -------------------------------------------------------------------------------- 1 | export type Authority = 'STUDENT_COUNCIL' | 'STUDENT'; 2 | 3 | export interface UserInfoType { 4 | authority: string; 5 | email: string; 6 | schoolName: string; 7 | name: string; 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 학생청원 2 | > 학생청원은 학교와 학생 간의 소통을 증진하기 위한 교내 청원 서비스입니다. 3 | 4 | Stupetition Image 5 | -------------------------------------------------------------------------------- /src/types/common/input.type.ts: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes } from 'react'; 2 | 3 | export interface InputPropsType extends InputHTMLAttributes { 4 | desc?: string; 5 | width?: string; 6 | height?: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/Login/Council/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Council = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | margin: 42px 0px; 7 | gap: 18px; 8 | width: 425px; 9 | `; 10 | -------------------------------------------------------------------------------- /src/atoms/user.ts: -------------------------------------------------------------------------------- 1 | import { userEmpty } from 'fixtures'; 2 | import { atom } from 'recoil'; 3 | import { UserInfoType } from 'types/user.type'; 4 | 5 | export const userState = atom({ 6 | key: 'user', 7 | default: userEmpty, 8 | }); 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | name: "🙋🏻 Question" 2 | description: "질문이 있나요?" 3 | labels: "🙋🏻 Question" 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: ❓ Question 8 | description: 질문을 입력해 주세요. 9 | validations: 10 | required: true -------------------------------------------------------------------------------- /src/pages/Callback/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { color } from 'styles/theme.style'; 3 | 4 | export const CallbackLayout = styled.div` 5 | width: 100vw; 6 | height: 100vh; 7 | background-color: ${color.white}; 8 | border: 1px solid black; 9 | `; 10 | -------------------------------------------------------------------------------- /src/components/common/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import Header from 'components/common/Header'; 2 | import { Outlet } from 'react-router-dom'; 3 | 4 | const Layout = () => { 5 | return ( 6 | <> 7 |
8 | 9 | 10 | ); 11 | }; 12 | 13 | export default Layout; 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 80, 8 | "arrowParens": "always", 9 | "orderedImports": true, 10 | "bracketSpacing": true, 11 | "jsxBracketSameLine": false 12 | } -------------------------------------------------------------------------------- /src/constants/key.constant.ts: -------------------------------------------------------------------------------- 1 | export const PETITION = 'usePetition'; 2 | export const PETITION_LIST = 'usePetitionList'; 3 | export const PETITION_APPROVED = 'usePetitionApproved'; 4 | export const PETITION_WROTE = 'usePetitionWrote'; 5 | export const USER = 'useUserInfo'; 6 | export const GOOGLE_AUTH_LINK = 'useGoogleAuthLink'; 7 | -------------------------------------------------------------------------------- /src/libs/token/authorization.ts: -------------------------------------------------------------------------------- 1 | import { ACCESS_KEY, REQUEST_KEY } from 'constants/token.constant'; 2 | import { Storage } from 'libs/storage/storage'; 3 | 4 | export const authorization = () => { 5 | return { 6 | headers: { 7 | [REQUEST_KEY]: `Bearer ${Storage.getItem(ACCESS_KEY)}`, 8 | }, 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/utils/ScrollTop.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useLocation } from 'react-router-dom'; 3 | 4 | const ScrollTop = () => { 5 | const { pathname } = useLocation(); 6 | 7 | useEffect(() => { 8 | window.scrollTo(0, 0); 9 | }, [pathname]); 10 | 11 | return null; 12 | }; 13 | 14 | export default ScrollTop; 15 | -------------------------------------------------------------------------------- /src/utils/GlobalModal.tsx: -------------------------------------------------------------------------------- 1 | import { modalState } from 'atoms/modal'; 2 | import { useRecoilValue } from 'recoil'; 3 | 4 | const GlobalModal = () => { 5 | const modal = useRecoilValue(modalState); 6 | 7 | const provide = () => { 8 | if (!modal) return null; 9 | return modal; 10 | }; 11 | 12 | return <>{provide()}; 13 | }; 14 | 15 | export default GlobalModal; 16 | -------------------------------------------------------------------------------- /src/libs/storage/storage.ts: -------------------------------------------------------------------------------- 1 | type LocalStorageKey = 'access-token' | 'refresh-token'; 2 | export class Storage { 3 | static getItem(key: LocalStorageKey) { 4 | return typeof window !== 'undefined' ? localStorage.getItem(key) : null; 5 | } 6 | 7 | static setItem(key: LocalStorageKey, value: string) { 8 | if (typeof window === 'undefined') return; 9 | localStorage.setItem(key, value); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/common/modal.type.ts: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from 'react'; 2 | 3 | export type ModalOptionType = 'CONFIRM' | 'ALERT'; 4 | 5 | export interface ModalPropsType { 6 | option: ModalOptionType; 7 | title: string; 8 | content: string; 9 | canceltext: string; 10 | checktext: string; 11 | cancel: MouseEventHandler; 12 | check: MouseEventHandler; 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useModal.ts: -------------------------------------------------------------------------------- 1 | import { modalState } from 'atoms/modal'; 2 | import { ReactNode } from 'react'; 3 | import { useRecoilState } from 'recoil'; 4 | 5 | export const useModal = () => { 6 | const [modal, setModal] = useRecoilState(modalState); 7 | const openModal = (m: ReactNode) => { 8 | setModal(m); 9 | }; 10 | const closeModal = () => { 11 | setModal(null); 12 | }; 13 | 14 | return { openModal, closeModal, modal }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/styles/theme.style.ts: -------------------------------------------------------------------------------- 1 | export const color = { 2 | main: '#2979FF', 3 | hover: '#448AFF', 4 | red: '#F44336', 5 | disabled: '#B4D2FF', 6 | white: '#FFFFFF', 7 | black: '#202020', 8 | gray50: '#FAFAFA', 9 | gray100: '#F5F5F5', 10 | gray200: '#EEEEEE', 11 | gray300: '#E0E0E0', 12 | gray400: '#BDBDBD', 13 | gray500: '#9E9E9E', 14 | gray600: '#757575', 15 | gray700: '#616161', 16 | gray800: '#424242', 17 | gray900: '#212121', 18 | }; 19 | -------------------------------------------------------------------------------- /src/assets/loginProfile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/utils/ProgressChecker.tsx: -------------------------------------------------------------------------------- 1 | import { StatusType } from 'types/petition.type'; 2 | 3 | export const ProgressChecker = (status: StatusType) => { 4 | if (status === 'PETITION') return { color: '#66BB6A', progress: '진행' }; 5 | if (status === 'ANSWERED') return { color: '#2979FF', progress: '완료' }; 6 | if (status === 'WAITING') return { color: '#FFA000', progress: '대기' }; 7 | if (status === 'EXPIRED') return { color: '#F44336', progress: '만료' }; 8 | return { color: '#66BB6A', progress: '진행' }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/assets/profile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/common/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import ClipLoader from 'react-spinners/ClipLoader'; 3 | import { color } from 'styles/theme.style'; 4 | 5 | const SpinnerBox = styled.div` 6 | position: fixed; 7 | top: 50%; 8 | left: 50%; 9 | transform: translate(-50%, -50%); 10 | `; 11 | 12 | const Loading = () => { 13 | return ( 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default Loading; 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | .env 3 | .idea 4 | 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | .yarn/* 29 | !.yarn/patches 30 | !.yarn/releases 31 | !.yarn/plugins 32 | !.yarn/sdks 33 | !.yarn/versions 34 | .pnp.* -------------------------------------------------------------------------------- /src/pages/Main/Banner/index.tsx: -------------------------------------------------------------------------------- 1 | import { SetStateAction, Dispatch } from 'react'; 2 | import * as S from './style'; 3 | 4 | interface PropsType { 5 | setIsBannerOpen: Dispatch>; 6 | } 7 | 8 | const Banner = ({ setIsBannerOpen }: PropsType) => { 9 | return ( 10 | 11 | 12 | 학생청원, 13 |
14 | 학생들의 목소리를 듣다 15 | setIsBannerOpen(false)}>X 닫기 16 |
17 |
18 | ); 19 | }; 20 | 21 | export default Banner; 22 | -------------------------------------------------------------------------------- /src/components/Ment/index.tsx: -------------------------------------------------------------------------------- 1 | import Logo from 'assets/logo.svg'; 2 | import * as S from './style'; 3 | 4 | interface PropsType { 5 | posistion: 'flex-end' | 'flex-start'; 6 | } 7 | 8 | const Ment = ({ posistion }: PropsType) => { 9 | return ( 10 | 11 | 12 | 13 | 학생청원 14 | 15 | 16 | 학교에게 하고싶었던 말, 17 |
18 | 이제는 모두 학생청원으로 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default Ment; 25 | -------------------------------------------------------------------------------- /src/hooks/useTokenCheck.ts: -------------------------------------------------------------------------------- 1 | import { ACCESS_KEY, REFRESH_KEY } from 'constants/token.constant'; 2 | import { useEffect } from 'react'; 3 | import { Storage } from 'libs/storage/storage'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | const useTokenCheck = () => { 7 | const navigate = useNavigate(); 8 | 9 | useEffect(() => { 10 | if ( 11 | Storage.getItem(ACCESS_KEY) === null || 12 | Storage.getItem(REFRESH_KEY) === null 13 | ) { 14 | navigate('/login'); 15 | } 16 | }, [navigate]); 17 | }; 18 | 19 | export default useTokenCheck; 20 | -------------------------------------------------------------------------------- /src/components/common/Input/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { font } from 'styles/text.style'; 3 | import { color } from 'styles/theme.style'; 4 | 5 | export const Desc = styled.p` 6 | ${font.caption} 7 | color: ${color.gray600}; 8 | padding-bottom: 12px; 9 | `; 10 | 11 | export const Input = styled.input` 12 | ${font.p2} 13 | height: 48px; 14 | width: 100%; 15 | padding: 0px 16px; 16 | border-radius: 8px; 17 | background-color: ${color.gray100}; 18 | color: ${color.gray900}; 19 | &::placeholder { 20 | color: ${color.gray500}; 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /src/pages/NotFound/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { font } from 'styles/text.style'; 3 | import { color } from 'styles/theme.style'; 4 | 5 | const NotFoundText = styled.p` 6 | ${font.H1} 7 | color: ${color.gray900}; 8 | text-align: center; 9 | position: fixed; 10 | top: 50%; 11 | left: 50%; 12 | transform: translate(-50%, -50%); 13 | `; 14 | 15 | const NotFound = () => { 16 | return ( 17 | 18 | 404 Error 19 |
20 | 2학년 2반 김석진에게 문의하세요. 21 |
22 | ); 23 | }; 24 | 25 | export default NotFound; 26 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/common/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import { InputPropsType } from 'types/common/input.type'; 2 | import * as S from './style'; 3 | 4 | const Input = ({ 5 | desc, 6 | placeholder, 7 | type, 8 | name, 9 | value, 10 | onChange, 11 | }: InputPropsType) => { 12 | return ( 13 |
14 | {desc && {desc}} 15 | 23 |
24 | ); 25 | }; 26 | 27 | export default Input; 28 | -------------------------------------------------------------------------------- /src/components/common/MiniButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes } from 'react'; 2 | import { MiniButtonOptionType } from 'types/common/button.type'; 3 | import * as S from './style'; 4 | 5 | interface MiniButtonPropsType extends ButtonHTMLAttributes { 6 | width?: string; 7 | option: MiniButtonOptionType; 8 | } 9 | 10 | const MiniButton = ({ value, onClick, width, option }: MiniButtonPropsType) => { 11 | return ( 12 | 13 | {value} 14 | 15 | ); 16 | }; 17 | 18 | export default MiniButton; 19 | -------------------------------------------------------------------------------- /src/features/LogoutFeature.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from 'react-query'; 2 | import { logoutUser } from 'api/user.api'; 3 | import { useSetRecoilState } from 'recoil'; 4 | import { userState } from 'atoms/user'; 5 | import { useNavigate } from 'react-router-dom'; 6 | import { userEmpty } from 'fixtures'; 7 | 8 | /** 로그아웃 */ 9 | export const useLogoutMutation = () => { 10 | const navigate = useNavigate(); 11 | const setUser = useSetRecoilState(userState); 12 | 13 | return useMutation(logoutUser, { 14 | onSuccess: () => { 15 | localStorage.clear(); 16 | setUser(userEmpty); 17 | navigate('/login'); 18 | }, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: '✨ Feature' 2 | description: '새로운 기능에 대한 issue를 작성하세요!' 3 | labels: '✨ Feature' 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: ✨ Describe 8 | description: 새로운 기능에 대한 설명을 작성해 주세요. 9 | placeholder: 꼼꼼하게 적을수록 좋습니다! 10 | validations: 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: ✅ Tasks 15 | description: 해야 하는 일에 대한 Tasks를 작성해 주세요. 16 | placeholder: ◻︎ User Entity 작성 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: 🙋🏻 More 22 | description: 더 하고 싶은 말이 있다면 작성해 주세요. 23 | -------------------------------------------------------------------------------- /src/api/auth.api.ts: -------------------------------------------------------------------------------- 1 | import { customAxios } from 'libs/axios/customAxios'; 2 | import { LoginType } from 'types/auth.type'; 3 | 4 | /** 학생회 로그인 */ 5 | export const loginUser = async (loginData: LoginType) => { 6 | const { data } = await customAxios.post('/auth', loginData); 7 | return data; 8 | }; 9 | 10 | /** 구글 로그인하러 가는 링크 반환 */ 11 | export const getGoogleAuthLink = async () => { 12 | const { data } = await customAxios.get('/auth/google'); 13 | return data; 14 | }; 15 | 16 | /** 구글 로그인 */ 17 | export const authGoogle = async (code: string) => { 18 | const { data } = await customAxios.post(`/auth/google/callback?code=${code}`); 19 | return data; 20 | }; 21 | -------------------------------------------------------------------------------- /src/libs/token/tokenExpired.ts: -------------------------------------------------------------------------------- 1 | import { customAxios } from 'libs/axios/customAxios'; 2 | import { ACCESS_KEY, REFRESH_KEY } from 'constants/token.constant'; 3 | import { toast } from 'react-toastify'; 4 | import { Storage } from 'libs/storage/storage'; 5 | 6 | export const tokenExpired = async () => { 7 | try { 8 | const { data } = await customAxios.put('/auth', null, { 9 | headers: { 10 | 'Refresh-Token': `${Storage.getItem(REFRESH_KEY)}`, 11 | }, 12 | }); 13 | Storage.setItem(ACCESS_KEY, data.accessToken); 14 | } catch { 15 | toast.error('다시 로그인 해주세요'); 16 | localStorage.clear(); 17 | window.location.href = '/login'; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/common/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes } from 'react'; 2 | import { ButtonOptionType } from 'types/common/button.type'; 3 | import * as S from './style'; 4 | 5 | interface ButtonPropsType extends ButtonHTMLAttributes { 6 | width?: string; 7 | imgSrc?: string; 8 | option: ButtonOptionType; 9 | } 10 | 11 | const Button = ({ 12 | imgSrc, 13 | onClick, 14 | width = '88px', 15 | option, 16 | value, 17 | }: ButtonPropsType) => { 18 | return ( 19 | 20 | {imgSrc && } 21 | {value} 22 | 23 | ); 24 | }; 25 | 26 | export default Button; 27 | -------------------------------------------------------------------------------- /src/api/user.api.ts: -------------------------------------------------------------------------------- 1 | import { customAxios } from 'libs/axios/customAxios'; 2 | import { authorization } from 'libs/token/authorization'; 3 | import { Storage } from 'libs/storage/storage'; 4 | import { ACCESS_KEY, REFRESH_KEY, REQUEST_KEY } from 'constants/token.constant'; 5 | 6 | /** user info 얻어오기 */ 7 | export const getUser = async () => { 8 | const { data } = await customAxios.get('/user', authorization()); 9 | return data; 10 | }; 11 | 12 | /** 로그아웃 */ 13 | export const logoutUser = async () => { 14 | await customAxios.delete('/auth', { 15 | headers: { 16 | [REQUEST_KEY]: `Bearer ${Storage.getItem(ACCESS_KEY)}`, 17 | 'Refresh-Token': `${Storage.getItem(REFRESH_KEY)}`, 18 | }, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/assets/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/Progressbar/style.ts: -------------------------------------------------------------------------------- 1 | import { color } from 'styles/theme.style'; 2 | import styled from 'styled-components'; 3 | import { font } from 'styles/text.style'; 4 | 5 | export const ProgressBar = styled.div` 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | `; 10 | 11 | export const PercentSmall = styled.p` 12 | ${font.H5} 13 | color: ${color.gray600}; 14 | `; 15 | 16 | export const PersonnelSmall = styled.p` 17 | ${font.caption} 18 | color: ${color.gray600}; 19 | `; 20 | 21 | export const PercentLarge = styled.p` 22 | ${font.H2} 23 | color: ${color.gray600}; 24 | `; 25 | 26 | export const PersonnelLarge = styled.p` 27 | ${font.p1} 28 | color: ${color.gray600}; 29 | `; 30 | -------------------------------------------------------------------------------- /src/pages/Callback/index.tsx: -------------------------------------------------------------------------------- 1 | import { useGoogleLoginMutation } from 'features/GoogleLoginFeature'; 2 | import Loading from 'components/common/Loading'; 3 | import queryString from 'query-string'; 4 | import { useEffect } from 'react'; 5 | import * as S from './style'; 6 | 7 | const Callback = () => { 8 | const googleLoginMutate = useGoogleLoginMutation(); 9 | 10 | useEffect(() => { 11 | const q = queryString.parse(window.location.search); 12 | if (q.code !== undefined && typeof q.code === 'string') { 13 | googleLoginMutate.mutate(q.code); 14 | } 15 | }, []); 16 | 17 | return ( 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default Callback; 25 | -------------------------------------------------------------------------------- /src/components/Ment/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { font } from 'styles/text.style'; 3 | import { color } from 'styles/theme.style'; 4 | 5 | export const Ment = styled.div` 6 | display: flex; 7 | flex-direction: column; 8 | gap: 24px; 9 | // 나중에 수정합시다 10 | @media screen and (max-width: 1280px) { 11 | display: none; 12 | } 13 | `; 14 | 15 | export const Text = styled.p` 16 | ${font.H2} 17 | color: rgba(0, 0, 0, 0.7); 18 | font-weight: 600; 19 | `; 20 | 21 | export const Logo = styled.div` 22 | ${font.H5} 23 | color: ${color.black}; 24 | height: 100%; 25 | display: flex; 26 | align-items: center; 27 | gap: 6px; 28 | cursor: pointer; 29 | `; 30 | 31 | export const Icon = styled.img``; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "typeRoots": ["./node_modules/@types", "types"], 10 | "baseUrl": "./src", 11 | "allowJs": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "allowSyntheticDefaultImports": true, 15 | "strict": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "jsx": "react-jsx" 24 | }, 25 | "include": ["src","src/App.tsx", "src/index.tsx" ,"@types/module.d.ts"] 26 | } -------------------------------------------------------------------------------- /src/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | import { PetitionDetailType } from 'types/petition.type'; 2 | import { UserInfoType } from 'types/user.type'; 3 | 4 | export const petitionDetailData: PetitionDetailType = { 5 | answer: [], 6 | approved: false, 7 | comments: [], 8 | content: '', 9 | createdAt: '0000-00-00T00:00:00', 10 | status: 'ANSWERED', 11 | title: '', 12 | numberOfApprover: 0, 13 | percentageOfApprover: 0, 14 | id: 0, 15 | hasPermission: false, 16 | writer: { 17 | name: '학생', 18 | authority: 'ROLE_STUDENT', 19 | email: '', 20 | schoolName: '', 21 | status: 'ATTENDING', 22 | userId: 0, 23 | }, 24 | }; 25 | 26 | export const userEmpty: UserInfoType = { 27 | authority: '', 28 | email: '', 29 | schoolName: '', 30 | name: '', 31 | }; 32 | -------------------------------------------------------------------------------- /src/pages/MyPetition/style.ts: -------------------------------------------------------------------------------- 1 | import { color } from 'styles/theme.style'; 2 | import styled from 'styled-components'; 3 | 4 | export const MyPetitionLayout = styled.div` 5 | width: 100vw; 6 | height: 100vh; 7 | min-height: 100vh; 8 | background-color: ${color.white}; 9 | `; 10 | 11 | export const MyPetitionWrap = styled.div` 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | width: 100%; 16 | height: 100%; 17 | `; 18 | 19 | export const ContentsBox = styled.div` 20 | width: 74.4%; 21 | `; 22 | 23 | export const RadioTabMenuWrap = styled.div` 24 | margin: 48px 0px 32px 0px; 25 | `; 26 | 27 | export const PetitionListBox = styled.div` 28 | display: grid; 29 | grid-template: auto / repeat(2, 49%); 30 | gap: 7% 2%; 31 | width: 100%; 32 | `; 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: '🐞 Bug' 2 | description: '버그가 발생했나요?' 3 | labels: '🐞 Bug' 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: 🐞 Describe 8 | description: 버그에 대한 설명을 작성해 주세요. 9 | placeholder: 꼼꼼하게 적을수록 좋습니다! 10 | validations: 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: 📄 Logs 15 | description: 로그 있으면 복붙해 주세요. 16 | render: shell 17 | validations: 18 | required: false 19 | - type: textarea 20 | attributes: 21 | label: 🌏 Environment 22 | description: 버그가 발생한 환경에 대해 작성해 주세요. 23 | placeholder: | 24 | OS: macOS 12.2.1 25 | validations: 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: 🙋🏻 More 30 | description: 더 하고 싶은 말이 있다면 작성해 주세요. 31 | -------------------------------------------------------------------------------- /src/pages/Main/Banner/style.ts: -------------------------------------------------------------------------------- 1 | import BannerImg from 'assets/banner.png'; 2 | import { color } from 'styles/theme.style'; 3 | import { font } from 'styles/text.style'; 4 | import styled from 'styled-components'; 5 | 6 | export const Banner = styled.div` 7 | position: relative; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | background-image: url(${BannerImg}); 12 | background-size: cover; 13 | height: 50%; 14 | `; 15 | 16 | export const BannerWrap = styled.div` 17 | ${font.D2} 18 | color: ${color.gray900}; 19 | 20 | width: 74.4%; 21 | `; 22 | 23 | export const Close = styled.p` 24 | ${font.caption} 25 | position: absolute; 26 | color: ${color.gray600}; 27 | border-bottom: 1px solid ${color.gray600}; 28 | cursor: pointer; 29 | right: 32px; 30 | bottom: 21px; 31 | `; 32 | -------------------------------------------------------------------------------- /src/features/WritePetitionFeature.ts: -------------------------------------------------------------------------------- 1 | import { createPetition } from 'api/petition.api'; 2 | import { useModal } from 'hooks/useModal'; 3 | import { useMutation } from 'react-query'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { toast } from 'react-toastify'; 6 | import { WritePetitionType } from 'types/petition.type'; 7 | 8 | /** 청원 만들기 */ 9 | export const useCreatePetition = (petitionData: WritePetitionType) => { 10 | const navigate = useNavigate(); 11 | const { closeModal } = useModal(); 12 | 13 | return useMutation(() => createPetition(petitionData), { 14 | onSuccess: () => { 15 | toast.success('작성 성공'); 16 | closeModal(); 17 | navigate('/'); 18 | }, 19 | onError: () => { 20 | toast.error('크기가 100에서 4000 사이여야 합니다'); 21 | closeModal(); 22 | }, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /src/features/LoginFeature.ts: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import { ACCESS_KEY, REFRESH_KEY } from 'constants/token.constant'; 3 | import { useMutation } from 'react-query'; 4 | import { loginUser } from 'api/auth.api'; 5 | import { LoginType } from 'types/auth.type'; 6 | import { Storage } from 'libs/storage/storage'; 7 | import { toast } from 'react-toastify'; 8 | 9 | /** 학생회 로그인 */ 10 | export const useLoginMutation = (loginData: LoginType) => { 11 | const navigate = useNavigate(); 12 | 13 | return useMutation(() => loginUser(loginData), { 14 | onSuccess: (res) => { 15 | const { accessToken, refreshToken } = res; 16 | 17 | Storage.setItem(ACCESS_KEY, accessToken); 18 | Storage.setItem(REFRESH_KEY, refreshToken); 19 | navigate('/'); 20 | toast.success('로그인 성공'); 21 | }, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/common/SearchInput/index.tsx: -------------------------------------------------------------------------------- 1 | import { InputPropsType } from 'types/common/input.type'; 2 | import SearchSvg from 'assets/search.svg'; 3 | import * as S from './style'; 4 | 5 | const SearchInput = ({ 6 | desc, 7 | placeholder, 8 | type, 9 | name, 10 | value, 11 | width, 12 | height, 13 | onChange, 14 | onFocus, 15 | }: InputPropsType) => { 16 | return ( 17 |
18 | {desc && {desc}} 19 | 20 | 21 | 30 | 31 |
32 | ); 33 | }; 34 | 35 | export default SearchInput; 36 | -------------------------------------------------------------------------------- /src/features/PetitionListFeature.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import { getPetitions } from 'api/petition.api'; 3 | import { StatusType } from 'types/petition.type'; 4 | import * as KEY from 'constants/key.constant'; 5 | import { useUser } from 'hooks/useUser'; 6 | 7 | interface PetitionListType { 8 | status: StatusType; 9 | createdAt: string; 10 | id: number; 11 | numberOfApprover: number; 12 | percentageOfApprover: number; 13 | title: string; 14 | } 15 | 16 | /** 청원 리스트 얻어오기 */ 17 | export const usePetitionList = (status: StatusType) => { 18 | const { user } = useUser(); 19 | 20 | const { data, isLoading, isError } = useQuery( 21 | [KEY.PETITION_LIST, status], 22 | () => getPetitions(status), 23 | { 24 | enabled: !!user.authority, 25 | }, 26 | ); 27 | 28 | return { isLoading, isError, data: data || [] }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/assets/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import * as KEY from 'constants/key.constant'; 2 | import { useQuery } from 'react-query'; 3 | import { getUser } from 'api/user.api'; 4 | import { UserInfoType } from 'types/user.type'; 5 | import { Storage } from 'libs/storage/storage'; 6 | import { ACCESS_KEY } from 'constants/token.constant'; 7 | import { useRecoilState } from 'recoil'; 8 | import { userState } from 'atoms/user'; 9 | import { useEffect } from 'react'; 10 | import { useNavigate } from 'react-router-dom'; 11 | 12 | export const useUser = () => { 13 | const navigate = useNavigate(); 14 | const [userInfo, setUserInfo] = useRecoilState(userState); 15 | const { data: user } = useQuery([KEY.USER], () => getUser(), { 16 | enabled: !!Storage.getItem(ACCESS_KEY), 17 | }); 18 | 19 | useEffect(() => { 20 | if (user) setUserInfo(user); 21 | }, [setUserInfo, user, navigate]); 22 | 23 | return { user: userInfo }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/RadioTabMenu/style.ts: -------------------------------------------------------------------------------- 1 | import { color } from 'styles/theme.style'; 2 | import { font } from 'styles/text.style'; 3 | import styled from 'styled-components'; 4 | 5 | export const RadioTabMenu = styled.div` 6 | display: flex; 7 | align-items: center; 8 | gap: 12px; 9 | border-radius: 8px; 10 | background-color: ${color.white}; 11 | `; 12 | 13 | export const TabButton = styled.div``; 14 | export const RadioInput = styled.input` 15 | display: none; 16 | `; 17 | 18 | export const RadioLabel = styled.label` 19 | ${font.btn2} 20 | display: flex; 21 | justify-content: center; 22 | align-items: center; 23 | padding: 10px 16px; 24 | border-radius: 8px; 25 | color: ${color.gray600}; 26 | 27 | cursor: pointer; 28 | 29 | ${RadioInput}:hover + & { 30 | color: ${color.gray900}; 31 | } 32 | ${RadioInput}:checked + & { 33 | color: ${color.gray900}; 34 | background-color: ${color.gray200}; 35 | } 36 | `; 37 | -------------------------------------------------------------------------------- /src/components/common/SearchInput/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { font } from 'styles/text.style'; 3 | import { color } from 'styles/theme.style'; 4 | 5 | export const Desc = styled.p` 6 | ${font.caption} 7 | color: ${color.gray600}; 8 | padding-bottom: 12px; 9 | `; 10 | 11 | export const SearchInput = styled.div` 12 | height: 48px; 13 | width: 100%; 14 | display: flex; 15 | align-items: center; 16 | border-radius: 8px; 17 | background-color: ${color.gray100}; 18 | padding: 0px 0px 0px 16px; 19 | `; 20 | 21 | export const Img = styled.img` 22 | width: 18px; 23 | height: 18px; 24 | margin-right: 9px; 25 | `; 26 | 27 | export const Input = styled.input` 28 | ${font.p2} 29 | border-radius: 0px 8px 8px 0px; 30 | background-color: ${color.gray100}; 31 | color: ${color.gray900}; 32 | width: 100%; 33 | height: 100%; 34 | &::placeholder { 35 | color: ${color.gray500}; 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /src/components/RadioTabMenu/data.ts: -------------------------------------------------------------------------------- 1 | import { StatusType } from 'types/petition.type'; 2 | 3 | export type StatusNameType = 4 | | '진행중' 5 | | '대기중' 6 | | '완료' 7 | | '만료' 8 | | '동의한 청원' 9 | | '내가 쓴 청원'; 10 | 11 | interface TabDataType { 12 | id: number; 13 | option: StatusType; 14 | name: StatusNameType; 15 | } 16 | 17 | export const MainTabDatas: TabDataType[] = [ 18 | { 19 | id: 0, 20 | option: 'PETITION', 21 | name: '진행중', 22 | }, 23 | { 24 | id: 1, 25 | option: 'WAITING', 26 | name: '대기중', 27 | }, 28 | { 29 | id: 2, 30 | option: 'ANSWERED', 31 | name: '완료', 32 | }, 33 | { 34 | id: 3, 35 | option: 'EXPIRED', 36 | name: '만료', 37 | }, 38 | ]; 39 | 40 | export const MyPetitionTabDatas: TabDataType[] = [ 41 | { 42 | id: 0, 43 | option: 'WROTE', 44 | name: '내가 쓴 청원', 45 | }, 46 | { 47 | id: 1, 48 | option: 'APPROVED', 49 | name: '동의한 청원', 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /src/libs/axios/customAxios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { tokenExpired } from 'libs/token/tokenExpired'; 3 | import { toast } from 'react-toastify'; 4 | 5 | const customAxios = axios.create({ 6 | baseURL: process.env.REACT_APP_BASE_URL, 7 | timeout: 10000, 8 | headers: { 9 | 'Content-Type': 'application/json', 10 | }, 11 | }); 12 | 13 | customAxios.interceptors.request.use( 14 | (config) => { 15 | return config; 16 | }, 17 | (error) => { 18 | return Promise.reject(error); 19 | }, 20 | ); 21 | 22 | customAxios.interceptors.response.use( 23 | (response) => { 24 | return response; 25 | }, 26 | (error) => { 27 | const { status, code, message } = error.response.data; 28 | if (message) { 29 | if (status === 401 && code === 'EXPIRED_TOKEN') { 30 | tokenExpired(); 31 | } 32 | if (code !== 'EXPIRED_TOKEN') toast.error(message); 33 | } 34 | return Promise.reject(error); 35 | }, 36 | ); 37 | export { customAxios }; 38 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | import Layout from 'components/common/Layout'; 3 | import PetitionDetail from 'pages/PetitionDetail'; 4 | import CreatePetition from 'pages/WritePetition'; 5 | import MyPetition from 'pages/MyPetition'; 6 | import Main from 'pages/Main'; 7 | import Login from 'pages/Login'; 8 | import Callback from 'pages/Callback'; 9 | import useTokenCheck from 'hooks/useTokenCheck'; 10 | 11 | const App = () => { 12 | useTokenCheck(); 13 | 14 | return ( 15 | 16 | }> 17 | } /> 18 | } /> 19 | } /> 20 | 21 | } /> 22 | } /> 23 | } /> 24 | 25 | ); 26 | }; 27 | 28 | export default App; 29 | -------------------------------------------------------------------------------- /src/components/common/Header/ProfilePopover/style.ts: -------------------------------------------------------------------------------- 1 | import { color } from 'styles/theme.style'; 2 | import styled from 'styled-components'; 3 | import { font } from 'styles/text.style'; 4 | 5 | export const ProfilePopover = styled.div<{ display: string }>` 6 | display: ${(props) => props.display}; 7 | flex-direction: column; 8 | align-items: center; 9 | z-index: 1; 10 | position: absolute; 11 | right: 15px; 12 | top: 80px; 13 | width: 200px; 14 | height: 186px; 15 | padding: 24px 0px; 16 | border-radius: 8px; 17 | background-color: ${color.white}; 18 | `; 19 | 20 | export const Button = styled.button` 21 | ${font.p3} 22 | color: ${color.gray700}; 23 | text-align: left; 24 | width: 100%; 25 | height: 38px; 26 | padding: 8px 20px; 27 | background-color: ${color.white}; 28 | cursor: pointer; 29 | &:hover { 30 | background-color: ${color.gray100}; 31 | } 32 | `; 33 | 34 | export const Line = styled.div` 35 | width: 160px; 36 | border: 0.9px solid ${color.gray300}; 37 | margin: 8px 0px; 38 | `; 39 | -------------------------------------------------------------------------------- /src/styles/global.style.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | import reset from 'styled-reset'; 3 | 4 | const GlobalStyled = createGlobalStyle` 5 | ${reset}; 6 | body{-ms-overflow-style:none; } 7 | body::-webkit-scrollbar { display:none; } 8 | * { 9 | line-height: 135%; 10 | letter-spacing: -1%; 11 | box-sizing: border-box; 12 | font-family: 'Pretendard Variable'; 13 | margin: 0; 14 | padding: 0; 15 | } 16 | a{ 17 | text-decoration: none; 18 | color: inherit; 19 | } 20 | input, textarea { 21 | -moz-user-select: auto; 22 | -webkit-user-select: auto; 23 | -ms-user-select: auto; 24 | user-select: auto; 25 | border: none; 26 | outline: none; 27 | } 28 | input:focus { 29 | outline: none; 30 | } 31 | button { 32 | outline: none; 33 | border: none; 34 | background: none; 35 | padding: 0; 36 | cursor: pointer; 37 | } 38 | `; 39 | 40 | export default GlobalStyled; 41 | -------------------------------------------------------------------------------- /src/components/common/Button/style.ts: -------------------------------------------------------------------------------- 1 | import styled, { css, FlattenSimpleInterpolation } from 'styled-components'; 2 | import { font } from 'styles/text.style'; 3 | import { color } from 'styles/theme.style'; 4 | import { ButtonOptionType } from 'types/common/button.type'; 5 | 6 | export const Button = styled.button<{ option: ButtonOptionType }>` 7 | ${font.H5} 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | gap: 8px; 12 | padding: 12px 22px; 13 | border-radius: 8px; 14 | ${({ option }) => option && getButtonStyle[option]} 15 | `; 16 | 17 | export const Img = styled.img``; 18 | 19 | const getButtonStyle: Record = { 20 | FILLED: css` 21 | color: ${color.white}; 22 | background-color: ${color.main}; 23 | &:hover { 24 | background-color: ${color.hover}; 25 | } 26 | `, 27 | UNFILLED: css` 28 | color: ${color.gray600}; 29 | border: 1px solid ${color.gray200}; 30 | &:hover { 31 | border: 1px solid ${color.gray300}; 32 | color: ${color.gray900}; 33 | } 34 | `, 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/common/Header/style.ts: -------------------------------------------------------------------------------- 1 | import { color } from 'styles/theme.style'; 2 | import styled from 'styled-components'; 3 | import { font } from 'styles/text.style'; 4 | 5 | export const Header = styled.div` 6 | display: flex; 7 | justify-content: center; 8 | z-index: 1; 9 | width: 100vw; 10 | height: 64px; 11 | background-color: ${color.white}; 12 | @media print { 13 | display: none; 14 | } 15 | `; 16 | 17 | export const HeaderWrap = styled.div` 18 | display: flex; 19 | align-items: center; 20 | justify-content: space-between; 21 | width: 86.1%; 22 | height: 100%; 23 | background-color: ${color.white}; 24 | `; 25 | 26 | export const Logo = styled.div` 27 | ${font.H5} 28 | color: ${color.black}; 29 | 30 | height: 100%; 31 | display: flex; 32 | align-items: center; 33 | gap: 6px; 34 | cursor: pointer; 35 | `; 36 | 37 | export const Icon = styled.img``; 38 | 39 | export const Profile = styled.img` 40 | cursor: pointer; 41 | `; 42 | 43 | export const NavWrap = styled.div` 44 | display: flex; 45 | align-items: center; 46 | gap: 32px; 47 | height: 40px; 48 | `; 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 밤돌이로 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/types/petition.type.ts: -------------------------------------------------------------------------------- 1 | export type StatusType = 2 | | 'PETITION' 3 | | 'EXPIRED' 4 | | 'WAITING' 5 | | 'ANSWERED' 6 | | 'APPROVED' 7 | | 'WROTE'; 8 | 9 | export interface WritePetitionType { 10 | title: string; 11 | content: string; 12 | } 13 | 14 | export interface CommentType { 15 | id: number; 16 | comment: string; 17 | createdAt: string; 18 | hasPermission: boolean; 19 | writer: Writer; 20 | } 21 | 22 | export interface AnswerType { 23 | id: number; 24 | comment: string; 25 | createdAt: string; 26 | hasPermission: boolean; 27 | } 28 | 29 | export interface Writer { 30 | authority: string; 31 | email: string; 32 | schoolName: string; 33 | status: string; 34 | userId: number; 35 | name: string; 36 | } 37 | 38 | export interface PetitionDetailType { 39 | comments: CommentType[]; 40 | content: string; 41 | id: number; 42 | numberOfApprover: number; 43 | percentageOfApprover: number; 44 | status: StatusType; 45 | answer: AnswerType[]; 46 | title: string; 47 | createdAt: string; 48 | approved: boolean; 49 | writer: Writer; 50 | hasPermission: boolean; 51 | } 52 | -------------------------------------------------------------------------------- /src/features/GoogleLoginFeature.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from 'react-query'; 2 | import { authGoogle, getGoogleAuthLink } from 'api/auth.api'; 3 | import * as KEY from 'constants/key.constant'; 4 | import { Storage } from 'libs/storage/storage'; 5 | import { ACCESS_KEY, REFRESH_KEY } from 'constants/token.constant'; 6 | import { toast } from 'react-toastify'; 7 | import { useNavigate } from 'react-router-dom'; 8 | 9 | /** 구글 로그인하러 가는 링크 반환 */ 10 | export const useGoogleLink = () => { 11 | const { data } = useQuery([KEY.GOOGLE_AUTH_LINK], getGoogleAuthLink); 12 | return { data }; 13 | }; 14 | 15 | /** 구글 로그인 */ 16 | export const useGoogleLoginMutation = () => { 17 | const navigate = useNavigate(); 18 | 19 | return useMutation(authGoogle, { 20 | onSuccess: (res) => { 21 | const { accessToken, refreshToken } = res; 22 | Storage.setItem(ACCESS_KEY, accessToken); 23 | Storage.setItem(REFRESH_KEY, refreshToken); 24 | toast.success('로그인 성공'); 25 | navigate('/'); 26 | }, 27 | onError: () => { 28 | toast.error('학교 계정으로 로그인 해주세요'); 29 | navigate('/login'); 30 | }, 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/styles/text.style.ts: -------------------------------------------------------------------------------- 1 | const fontGenerator = ( 2 | weight: number, 3 | size: number, 4 | lineHeight: number, 5 | letterSpacing: number, 6 | ) => 7 | ` 8 | font-family: 'Pretendard Variable'; 9 | font-weight: ${weight}; 10 | font-size: ${size}rem; 11 | line-height: ${lineHeight}%; 12 | letter-spacing: ${letterSpacing}px; 13 | `; 14 | 15 | export const font = { 16 | D1: fontGenerator(700, 4.5, 130, -1.5), 17 | D2: fontGenerator(700, 3.75, 130, -0.5), 18 | D3: fontGenerator(600, 3, 130, 0), 19 | 20 | H1: fontGenerator(700, 2.25, 140, 0.25), 21 | H2: fontGenerator(700, 1.75, 140, 0), 22 | H3: fontGenerator(600, 1.5, 140, 0.15), 23 | H4: fontGenerator(600, 1.25, 140, 0.15), 24 | H5: fontGenerator(600, 1.125, 140, 0.15), 25 | 26 | p1: fontGenerator(400, 1.125, 140, 0.15), 27 | p2: fontGenerator(400, 1, 160, -0.15), 28 | p3: fontGenerator(400, 0.875, 160, -0.1), 29 | 30 | btn: fontGenerator(600, 1.125, 160, -0.15), 31 | btn1: fontGenerator(500, 1, 130, 0), 32 | btn2: fontGenerator(500, 0.875, 130, 0), 33 | 34 | caption: fontGenerator(400, 0.75, 140, 0), 35 | context: fontGenerator(500, 0.875, 130, 0), 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/RadioTabMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react'; 2 | import { StatusType } from 'types/petition.type'; 3 | import { MainTabDatas, MyPetitionTabDatas } from './data'; 4 | import * as S from './style'; 5 | 6 | type RadioOptionType = 'MAIN' | 'MY_PETITION'; 7 | 8 | interface PropsType { 9 | status: string; 10 | setStatus: Dispatch>; 11 | option: RadioOptionType; 12 | } 13 | 14 | const RadioTabMenu = ({ status, setStatus, option }: PropsType) => { 15 | const data = option === 'MAIN' ? MainTabDatas : MyPetitionTabDatas; 16 | return ( 17 | 18 | {data.map((item) => ( 19 | 20 | setStatus(item.option)} 26 | checked={status === item.option} 27 | /> 28 | {item.name} 29 | 30 | ))} 31 | 32 | ); 33 | }; 34 | 35 | export default RadioTabMenu; 36 | -------------------------------------------------------------------------------- /src/features/MyPetitionFeature.ts: -------------------------------------------------------------------------------- 1 | import { getApprovedPetitions, getWrotePetitions } from 'api/petition.api'; 2 | import { useQuery } from 'react-query'; 3 | import { StatusType } from 'types/petition.type'; 4 | import * as KEY from 'constants/key.constant'; 5 | import { useUser } from 'hooks/useUser'; 6 | 7 | interface MyPetitionList { 8 | createdAt: string; 9 | title: string; 10 | numberOfApprover: number; 11 | percentageOfApprover: number; 12 | id: number; 13 | status: StatusType; 14 | } 15 | 16 | /** 내 청원 불러오기 */ 17 | export const useMyPetitionList = (status: StatusType) => { 18 | const { user } = useUser(); 19 | 20 | const approvedPetitionList = useQuery( 21 | [KEY.PETITION_APPROVED], 22 | () => getApprovedPetitions(), 23 | { 24 | enabled: !!user.authority, 25 | }, 26 | ); 27 | 28 | const wrotePetitionList = useQuery( 29 | [KEY.PETITION_WROTE], 30 | () => getWrotePetitions(), 31 | { 32 | enabled: !!user.authority, 33 | }, 34 | ); 35 | 36 | const { data, isLoading, isError } = 37 | status === 'WROTE' ? wrotePetitionList : approvedPetitionList; 38 | 39 | return { data: data || [], isLoading, isError }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/common/Header/ProfilePopover/index.tsx: -------------------------------------------------------------------------------- 1 | import { useLogoutMutation } from 'features/LogoutFeature'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Dispatch, SetStateAction } from 'react'; 4 | import * as S from './style'; 5 | 6 | interface PropsType { 7 | isOpen: boolean; 8 | setProfilePopoverIsOpen: Dispatch>; 9 | } 10 | 11 | const ProfilePopover = ({ isOpen, setProfilePopoverIsOpen }: PropsType) => { 12 | const navigate = useNavigate(); 13 | const close = () => setProfilePopoverIsOpen(false); 14 | 15 | const logoutMutate = useLogoutMutation(); 16 | 17 | return ( 18 | 19 | { 21 | navigate('/petition/my'); 22 | close(); 23 | }} 24 | > 25 | 내 청원 26 | 27 | { 29 | window.open('https://www.instagram.com/bamdoliro/', '_blank'); 30 | close(); 31 | }} 32 | > 33 | 문의하기 34 | 35 | 36 | logoutMutate.mutate()}>로그아웃 37 | 38 | ); 39 | }; 40 | 41 | export default ProfilePopover; 42 | -------------------------------------------------------------------------------- /src/components/common/MiniButton/style.ts: -------------------------------------------------------------------------------- 1 | import { color } from 'styles/theme.style'; 2 | import styled, { css, FlattenSimpleInterpolation } from 'styled-components'; 3 | import { MiniButtonOptionType } from 'types/common/button.type'; 4 | import { font } from 'styles/text.style'; 5 | 6 | export const MiniButton = styled.button<{ option: MiniButtonOptionType }>` 7 | ${font.btn2} 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | border-radius: 8px; 12 | padding: 10px 16px; 13 | ${({ option }) => option && getMiniButtonStyle[option]} 14 | `; 15 | 16 | const getMiniButtonStyle: Record< 17 | MiniButtonOptionType, 18 | FlattenSimpleInterpolation 19 | > = { 20 | FILLED: css` 21 | color: ${color.white}; 22 | background-color: ${color.main}; 23 | &:hover { 24 | background-color: ${color.hover}; 25 | } 26 | `, 27 | UNFILLED: css` 28 | color: ${color.gray600}; 29 | background-color: ${color.white}; 30 | border: 1px solid ${color.gray200}; 31 | &:hover { 32 | border: 1px solid ${color.gray300}; 33 | color: ${color.gray900}; 34 | } 35 | `, 36 | SCARCE_FILLED: css` 37 | cursor: default; 38 | background-color: ${color.disabled}; 39 | color: ${color.white}; 40 | `, 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/common/Confirm/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { font } from 'styles/text.style'; 3 | import { color } from 'styles/theme.style'; 4 | 5 | export const BlurBackground = styled.div` 6 | top: 0; 7 | left: 0; 8 | position: fixed; 9 | width: 100vw; 10 | height: 100vh; 11 | background-color: rgba(0, 0, 0, 0.4); 12 | `; 13 | 14 | // confirm 15 | 16 | export const Confirm = styled.div` 17 | position: absolute; 18 | width: 416px; 19 | height: 268px; 20 | left: calc(50% - 416px / 2); 21 | top: calc(50% - 268px / 2); 22 | padding: 36px; 23 | background: ${color.white}; 24 | border-radius: 8px; 25 | `; 26 | 27 | export const ConfirmWrap = styled.div` 28 | height: 100%; 29 | display: flex; 30 | flex-direction: column; 31 | justify-content: space-between; 32 | `; 33 | 34 | export const ConfirmTextBox = styled.div` 35 | display: flex; 36 | flex-direction: column; 37 | gap: 12px; 38 | `; 39 | 40 | export const ConfirmTitle = styled.p` 41 | ${font.H3} 42 | color: ${color.gray900}; 43 | `; 44 | 45 | export const ConfirmContent = styled.p` 46 | ${font.p2} 47 | color: ${color.gray900}; 48 | `; 49 | 50 | export const ConfirmButtonBox = styled.div` 51 | display: flex; 52 | justify-content: space-between; 53 | `; 54 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import { QueryClient, QueryClientProvider } from 'react-query'; 3 | import GlobalStyled from 'styles/global.style'; 4 | import { RecoilRoot } from 'recoil'; 5 | import ScrollTop from 'utils/ScrollTop'; 6 | import { ToastContainer } from 'react-toastify'; 7 | import 'react-toastify/dist/ReactToastify.css'; 8 | import GlobalModal from 'utils/GlobalModal'; 9 | import { HelmetProvider } from 'react-helmet-async'; 10 | import { BrowserRouter } from 'react-router-dom'; 11 | import App from './App'; 12 | 13 | const queryClient = new QueryClient(); 14 | const root = ReactDOM.createRoot( 15 | document.getElementById('root') as HTMLElement, 16 | ); 17 | 18 | root.render( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 34 | 35 | 36 | 37 | , 38 | ); 39 | -------------------------------------------------------------------------------- /src/pages/WritePetition/style.ts: -------------------------------------------------------------------------------- 1 | import { color } from 'styles/theme.style'; 2 | import styled from 'styled-components'; 3 | import { font } from 'styles/text.style'; 4 | 5 | export const WritePetitionLayout = styled.div` 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | width: 100vw; 10 | height: 100vh; 11 | background-color: ${color.white}; 12 | `; 13 | 14 | export const Header = styled.div` 15 | display: flex; 16 | justify-content: center; 17 | width: 100%; 18 | height: 72px; 19 | margin-bottom: 46px; 20 | `; 21 | 22 | export const HeaderWrap = styled.div` 23 | width: 85%; 24 | display: flex; 25 | justify-content: space-between; 26 | align-items: flex-end; 27 | `; 28 | 29 | export const ContentsBox = styled.div` 30 | width: 56.6%; 31 | height: 70%; 32 | `; 33 | 34 | export const TitleInput = styled.input` 35 | ${font.H1} 36 | width: 100%; 37 | margin-bottom: 39px; 38 | &::placeholder { 39 | color: ${color.gray200}; 40 | } 41 | `; 42 | 43 | export const ContentInput = styled.textarea` 44 | ${font.p2} 45 | padding: 16px 20px; 46 | border: 1px solid ${color.gray200}; 47 | border-radius: 8px; 48 | width: 100%; 49 | height: 100%; 50 | resize: none; 51 | &::placeholder { 52 | color: ${color.gray400}; 53 | } 54 | `; 55 | -------------------------------------------------------------------------------- /src/pages/Main/style.ts: -------------------------------------------------------------------------------- 1 | import { color } from 'styles/theme.style'; 2 | import styled from 'styled-components'; 3 | 4 | export const MainLayout = styled.div` 5 | width: 100vw; 6 | height: 100vh; 7 | min-height: 100vh; 8 | background-color: ${color.white}; 9 | `; 10 | 11 | export const ContentsBox = styled.div` 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | width: 100%; 16 | `; 17 | 18 | export const ContentsWrap = styled.div` 19 | width: 74.4%; 20 | `; 21 | 22 | export const SubNav = styled.div` 23 | display: flex; 24 | align-items: center; 25 | justify-content: space-between; 26 | width: 100%; 27 | margin: 48px 0px 32px 0px; 28 | `; 29 | 30 | export const PetitionListBox = styled.div` 31 | display: grid; 32 | grid-template: auto / repeat(2, 49%); 33 | gap: 7% 2%; 34 | width: 100%; 35 | padding-bottom: 200px; 36 | @media only screen and (max-width: 1024px) { 37 | & { 38 | display: grid; 39 | grid-template: 100%; 40 | } 41 | } 42 | `; 43 | 44 | export const CreatePetition = styled.button` 45 | display: flex; 46 | align-items: center; 47 | justify-content: center; 48 | padding: 10px 16px; 49 | border-radius: 8px; 50 | background-color: ${color.main}; 51 | &:hover { 52 | background-color: ${color.hover}; 53 | } 54 | `; 55 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 28 | 학생청원 29 | 30 | 31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /src/components/common/Confirm/index.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler, ReactNode } from 'react'; 2 | import MiniButton from 'components/common/MiniButton'; 3 | import * as S from './style'; 4 | 5 | interface PropsType { 6 | title: string; 7 | content: ReactNode; 8 | closeText: string; 9 | confirmText: string; 10 | handleClose: MouseEventHandler; 11 | handleConfirm: MouseEventHandler; 12 | } 13 | 14 | const Confirm = ({ 15 | title, 16 | content, 17 | closeText, 18 | confirmText, 19 | handleClose, 20 | handleConfirm, 21 | }: PropsType) => { 22 | return ( 23 | 24 | 25 | 26 | 27 | {title} 28 | {content} 29 | 30 | 31 | 36 | 41 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export default Confirm; 49 | -------------------------------------------------------------------------------- /src/components/PetitionList/style.ts: -------------------------------------------------------------------------------- 1 | import { color } from 'styles/theme.style'; 2 | import { font } from 'styles/text.style'; 3 | import styled from 'styled-components'; 4 | 5 | export const PetitionList = styled.div` 6 | display: flex; 7 | height: 140px; 8 | width: 100%; 9 | border: 1px solid black; 10 | border: 1px solid ${color.gray200}; 11 | border-radius: 8px; 12 | cursor: pointer; 13 | `; 14 | 15 | export const PetitionListWrap = styled.div` 16 | display: flex; 17 | align-items: center; 18 | justify-content: space-between; 19 | width: 100%; 20 | padding: 0px 24px; 21 | `; 22 | 23 | export const Info = styled.div` 24 | width: 80%; 25 | display: flex; 26 | flex-direction: column; 27 | gap: 17px; 28 | `; 29 | 30 | export const Title = styled.p` 31 | ${font.H4} 32 | color: ${color.gray900}; 33 | max-width: 100%; 34 | text-overflow: ellipsis; 35 | overflow: hidden; 36 | white-space: nowrap; 37 | `; 38 | 39 | export const DetailInfo = styled.div` 40 | display: flex; 41 | align-items: center; 42 | gap: 12px; 43 | `; 44 | 45 | export const Date = styled.p` 46 | ${font.p3} 47 | color: ${color.gray600}; 48 | `; 49 | 50 | export const Progress = styled.div` 51 | ${font.caption} 52 | display: flex; 53 | justify-content: center; 54 | align-items: center; 55 | width: 48px; 56 | height: 24px; 57 | border: 1px solid ${(props) => props.color}; 58 | border-radius: 50px; 59 | color: ${(props) => props.color}; 60 | `; 61 | -------------------------------------------------------------------------------- /src/components/Progressbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CircularProgressbarWithChildren, 3 | buildStyles, 4 | } from 'react-circular-progressbar'; 5 | import { color } from 'styles/theme.style'; 6 | import { ProgressBarOption } from 'types/progressBar.type'; 7 | import * as S from './style'; 8 | 9 | interface PropsType { 10 | numberOfApprover: number; 11 | percentageOfApprover: number; 12 | width: string; 13 | height: string; 14 | option: ProgressBarOption; 15 | } 16 | 17 | const Progressbar = ({ 18 | percentageOfApprover, 19 | numberOfApprover, 20 | width, 21 | height, 22 | option, 23 | }: PropsType) => { 24 | return ( 25 | 26 | 34 | {option === 'LIST' ? ( 35 | <> 36 | {percentageOfApprover}% 37 | {numberOfApprover}명 38 | 39 | ) : ( 40 | <> 41 | {percentageOfApprover}% 42 | {numberOfApprover}명 43 | 44 | )} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default Progressbar; 51 | -------------------------------------------------------------------------------- /src/pages/Login/Council/index.tsx: -------------------------------------------------------------------------------- 1 | import Button from 'components/common/Button'; 2 | import Input from 'components/common/Input'; 3 | import { useLoginMutation } from 'features/LoginFeature'; 4 | import { ChangeEvent, useState } from 'react'; 5 | import { LoginType } from 'types/auth.type'; 6 | import * as S from './style'; 7 | 8 | const Council = () => { 9 | const [loginData, setLoginData] = useState({ 10 | username: '', 11 | password: '', 12 | }); 13 | 14 | const handleLoginData = (e: ChangeEvent) => { 15 | const { name, value } = e.target; 16 | setLoginData({ ...loginData, [name]: value }); 17 | }; 18 | 19 | const loginMutate = useLoginMutation(loginData); 20 | 21 | return ( 22 | <> 23 | 24 | 32 | 40 | 41 |