├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
}
21 | closeText="취소"
22 | confirmText="삭제"
23 | handleClose={closeModal}
24 | handleConfirm={() => deleteAnswerMutate.mutate()}
25 | />,
26 | );
27 | };
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | 학생회
38 |
39 |
40 | {date}
41 |
42 |
43 | {hasPermission && (
44 | 삭제
45 | )}
46 |
47 |
48 | {comment}
49 |
50 | );
51 | };
52 |
53 | export default Answer;
54 |
--------------------------------------------------------------------------------
/src/pages/PetitionDetail/Answer/style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { color } from 'styles/theme.style';
3 | import { font } from 'styles/text.style';
4 |
5 | export const Answer = styled.div`
6 | width: 100%;
7 | border-bottom: 1px solid ${color.gray200};
8 | margin-bottom: 28px;
9 | `;
10 |
11 | export const Info = styled.div`
12 | display: flex;
13 | `;
14 |
15 | export const InfoWrap = styled.div`
16 | width: 100%;
17 | display: flex;
18 | justify-content: space-between;
19 | align-items: flex-end;
20 | margin-bottom: 24px;
21 | `;
22 |
23 | export const ProfileWrap = styled.div`
24 | display: flex;
25 | align-items: flex-end;
26 | gap: 12px;
27 | `;
28 |
29 | export const Profile = styled.img`
30 | width: 56px;
31 | height: 56px;
32 | `;
33 |
34 | export const ItemWrap = styled.div`
35 | display: flex;
36 | flex-direction: column;
37 | `;
38 |
39 | export const NameWrap = styled.div`
40 | display: flex;
41 | align-items: flex-start;
42 | gap: 4px;
43 | `;
44 |
45 | export const Name = styled.p`
46 | ${font.p1}
47 | color: ${color.gray900};
48 | `;
49 |
50 | export const Check = styled.img`
51 | width: 24px;
52 | height: 24px;
53 | `;
54 |
55 | export const Date = styled.p`
56 | ${font.p3}
57 | color: ${color.gray500};
58 | `;
59 |
60 | export const Delete = styled.p`
61 | ${font.p3}
62 | color: ${color.gray600};
63 | cursor: pointer;
64 | &:hover {
65 | text-decoration-line: underline;
66 | text-decoration-color: ${color.gray600};
67 | }
68 | `;
69 |
70 | export const Content = styled.p`
71 | ${font.p1}
72 | white-space: pre-line;
73 | color: ${color.gray900};
74 | width: 87%;
75 | word-break: break-all;
76 | max-width: 87%;
77 | margin-bottom: 28px;
78 | `;
79 |
--------------------------------------------------------------------------------
/src/components/common/Header/index.tsx:
--------------------------------------------------------------------------------
1 | import Logo from 'assets/logo.svg';
2 | import Profile from 'assets/loginProfile.svg';
3 | import { useNavigate } from 'react-router-dom';
4 | import { useState } from 'react';
5 | import MiniButton from 'components/common/MiniButton';
6 | import SearchInput from 'components/common/SearchInput';
7 | import { useUser } from 'hooks/useUser';
8 | import ProfilePopover from './ProfilePopover';
9 | import * as S from './style';
10 |
11 | const Header = () => {
12 | const navigate = useNavigate();
13 | const { user } = useUser();
14 |
15 | const [profilePopoverIsOpen, setProfilePopoverIsOpen] = useState(false);
16 |
17 | return (
18 |
19 |
20 | navigate('/')}>
21 |
22 | 학생청원
23 |
24 |
25 |
32 | {user.authority ? (
33 | <>
34 | setProfilePopoverIsOpen(!profilePopoverIsOpen)}
37 | />
38 |
42 | >
43 | ) : (
44 | navigate('/login')}
48 | />
49 | )}
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default Header;
57 |
--------------------------------------------------------------------------------
/src/pages/Login/index.tsx:
--------------------------------------------------------------------------------
1 | import Button from 'components/common/Button';
2 | import Ment from 'components/Ment';
3 | import { useGoogleLink } from 'features/GoogleLoginFeature';
4 | import GoogleImg from 'assets/google.svg';
5 | import { useState } from 'react';
6 | import * as S from './style';
7 | import Council from './Council';
8 |
9 | const Login = () => {
10 | const [isOpenLoginCouncil, setIsOpenLoginCouncil] = useState(true);
11 | const { data } = useGoogleLink();
12 |
13 | return (
14 |
15 |
16 |
17 |
18 |
19 | 로그인
20 |
21 | {isOpenLoginCouncil ? (
22 | <>
23 | 학생회인가요?
24 | setIsOpenLoginCouncil(false)}>
25 | 학생회 로그인하러 가기
26 |
27 | >
28 | ) : (
29 | <>
30 | 학생인가요?
31 | setIsOpenLoginCouncil(true)}>
32 | 로그인하러 가기
33 |
34 | >
35 | )}
36 |
37 |
38 | {isOpenLoginCouncil ? (
39 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default Login;
56 |
--------------------------------------------------------------------------------
/src/pages/PetitionDetail/Comment/index.tsx:
--------------------------------------------------------------------------------
1 | import ProfileSvg from 'assets/profile.svg';
2 | import { FormatDatetime } from 'utils/FormatDatetime';
3 | import { CommentType } from 'types/petition.type';
4 | import { useModal } from 'hooks/useModal';
5 | import Modal from 'components/common/Confirm';
6 | import { useDeleteCommentMutation } from 'features/PetitionFeature';
7 | import { EmailReplace } from 'utils/EmailReplace';
8 | import * as S from './style';
9 |
10 | const Comment = ({
11 | comment,
12 | createdAt,
13 | id,
14 | hasPermission,
15 | writer,
16 | }: CommentType) => {
17 | const { openModal, closeModal } = useModal();
18 | const { date } = FormatDatetime(createdAt);
19 | const { userEmail } = EmailReplace(writer.email);
20 |
21 | const deleteCommentMutate = useDeleteCommentMutation(id);
22 |
23 | const checkDeleteComment = () => {
24 | openModal(
25 | 정말 댓글을 삭제 하시겠습니까?}
28 | closeText="취소"
29 | confirmText="삭제"
30 | handleClose={closeModal}
31 | handleConfirm={() => deleteCommentMutate.mutate()}
32 | />,
33 | );
34 | };
35 |
36 | return (
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | {writer.name} #{userEmail}
46 |
47 |
48 | {date}
49 |
50 |
51 | {hasPermission && (
52 | 삭제
53 | )}
54 |
55 |
56 | {comment}
57 |
58 | );
59 | };
60 |
61 | export default Comment;
62 |
--------------------------------------------------------------------------------
/src/pages/WritePetition/CheckWriteModal/index.tsx:
--------------------------------------------------------------------------------
1 | import MiniButton from 'components/common/MiniButton';
2 | import PetitionList from 'components/PetitionList';
3 | import { MouseEventHandler } from 'react';
4 | import { WritePetitionType } from 'types/petition.type';
5 | import { useCreatePetition } from 'features/WritePetitionFeature';
6 | import * as S from './style';
7 |
8 | interface PropsType {
9 | close: MouseEventHandler;
10 | petitionData: WritePetitionType;
11 | isOpenCheckWriteModal: boolean;
12 | }
13 |
14 | const CheckWriteModal = ({
15 | close,
16 | petitionData,
17 | isOpenCheckWriteModal,
18 | }: PropsType) => {
19 | const writePetitionMutate = useCreatePetition(petitionData);
20 |
21 | const date = {
22 | year: new Date().getFullYear(),
23 | month: (new Date().getMonth() + 1).toString().padStart(2, '0'),
24 | day: new Date().getDay().toString().padStart(2, '0'),
25 | };
26 |
27 | return (
28 |
29 |
30 |
31 |
32 | 최종 확인
33 |
34 | ⚠️ 남을 비방하는 말이나 부적절한 언어, 욕이 포함돼있을 경우
35 | 처벌받을 수 있습니다.
36 |
37 |
38 |
39 | 썸네일 미리보기
40 |
49 |
50 |
51 |
52 | writePetitionMutate.mutate()}
56 | />
57 |
58 |
59 |
60 |
61 | );
62 | };
63 |
64 | export default CheckWriteModal;
65 |
--------------------------------------------------------------------------------
/src/pages/Main/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { StatusType } from 'types/petition.type';
3 | import { useNavigate } from 'react-router-dom';
4 | import MiniButton from 'components/common/MiniButton';
5 | import { usePetitionList } from 'features/PetitionListFeature';
6 | import Loading from 'components/common/Loading';
7 | import NotFound from 'pages/NotFound';
8 | import { useUser } from 'hooks/useUser';
9 | import PetitionList from 'components/PetitionList';
10 | import RadioTabMenu from 'components/RadioTabMenu';
11 | import Banner from './Banner';
12 | import * as S from './style';
13 |
14 | const Main = () => {
15 | const navigate = useNavigate();
16 | const { user } = useUser();
17 | const [status, setStatus] = useState('PETITION');
18 | const [isBannerOpen, setIsBannerOpen] = useState(true);
19 | const { isLoading, isError, data } = usePetitionList(status);
20 |
21 | if (isLoading) return ;
22 | if (isError) return ;
23 | return (
24 |
25 | {isBannerOpen && }
26 |
27 |
28 |
29 |
30 | {user.authority === 'ROLE_STUDENT' && (
31 | navigate('/petition/write')}
33 | option="FILLED"
34 | value="청원하기"
35 | />
36 | )}
37 |
38 | {user.authority && (
39 |
40 | {data.map((item) => (
41 |
51 | ))}
52 |
53 | )}
54 |
55 |
56 |
57 | );
58 | };
59 |
60 | export default Main;
61 |
--------------------------------------------------------------------------------
/src/api/petition.api.ts:
--------------------------------------------------------------------------------
1 | import { customAxios } from 'libs/axios/customAxios';
2 | import { authorization } from 'libs/token/authorization';
3 | import { WritePetitionType, StatusType } from 'types/petition.type';
4 |
5 | export interface ReplyPetitionParamsType {
6 | comment: string;
7 | petitionId: number;
8 | }
9 |
10 | /** 청원 만들기 */
11 | export const createPetition = async (petitionData: WritePetitionType) => {
12 | await customAxios.post('/petition', petitionData, authorization());
13 | };
14 |
15 | /** 청원 리스트 얻어오기 */
16 | export const getPetitions = async (status: StatusType) => {
17 | const { data } = await customAxios.get(
18 | `/petition?status=${status}`,
19 | authorization(),
20 | );
21 | return data;
22 | };
23 |
24 | /** 청원 상세 페이지 데이터 얻어오기 */
25 | export const getPetitionDetail = async (id: number) => {
26 | const { data } = await customAxios.get(`/petition/${id}`, authorization());
27 | return data;
28 | };
29 |
30 | /** 청원 삭제 */
31 | export const deletePetition = async (id: number) => {
32 | await customAxios.delete(`/petition/${id}`, authorization());
33 | };
34 |
35 | /** 청원 동의하기 */
36 | export const approvePetition = async (petitionId: number) => {
37 | await customAxios.post(
38 | `/petition/${petitionId}/approve`,
39 | null,
40 | authorization(),
41 | );
42 | };
43 |
44 | /** 댓글 작성 (학생) */
45 | export const writeComment = async (commentData: ReplyPetitionParamsType) => {
46 | await customAxios.post(`/comment`, commentData, authorization());
47 | };
48 |
49 | /** 답변 작성 (학생회) */
50 | export const answerPetition = async (answerData: ReplyPetitionParamsType) => {
51 | await customAxios.post(`/answer`, answerData, authorization());
52 | };
53 |
54 | /** 댓글 삭제 (학생) */
55 | export const deleteComment = async (commentId: number) => {
56 | await customAxios.delete(`/comment/${commentId}`, authorization());
57 | };
58 |
59 | /** 답변 삭제 (학생회) */
60 | export const deleteAnswer = async (answerId: number) => {
61 | await customAxios.delete(`/answer/${answerId}`, authorization());
62 | };
63 |
64 | /** 내가 동의한 청원 얻어오기 */
65 | export const getApprovedPetitions = async () => {
66 | const { data } = await customAxios.get('/petition/approved', authorization());
67 | return data;
68 | };
69 |
70 | /** 내가 쓴 청원 얻어오기 */
71 | export const getWrotePetitions = async () => {
72 | const { data } = await customAxios.get('/petition/wrote', authorization());
73 | return data;
74 | };
75 |
--------------------------------------------------------------------------------
/src/pages/WritePetition/index.tsx:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, useState } from 'react';
2 | import { WritePetitionType } from 'types/petition.type';
3 | import MiniButton from 'components/common/MiniButton';
4 | import { useModal } from 'hooks/useModal';
5 | import { useNavigate } from 'react-router-dom';
6 | import Modal from 'components/common/Confirm';
7 | import * as S from './style';
8 | import CheckWriteModal from './CheckWriteModal';
9 |
10 | const WritePetition = () => {
11 | const navigate = useNavigate();
12 | const { openModal, closeModal } = useModal();
13 | const [isOpenCheckPetitionModal, setIsOpenCheckPetitionModal] =
14 | useState(false);
15 | const [petitionData, setPetitionData] = useState({
16 | title: '',
17 | content: '',
18 | });
19 | const handlePetitionData = (
20 | e: ChangeEvent,
21 | ) => {
22 | const { name, value } = e.target;
23 | setPetitionData({ ...petitionData, [name]: value });
24 | };
25 |
26 | const writeClose = () => {
27 | const close = () => closeModal();
28 | const confirm = () => {
29 | navigate('/');
30 | closeModal();
31 | };
32 | if (petitionData.content || petitionData.title) {
33 | openModal(
34 |
38 | 이대로 나가면 변경사항이 모두 삭제됩니다.
39 |
40 | 정말 이 페이지를 나가시겠습니까?
41 |
42 | }
43 | closeText="취소"
44 | confirmText="나가기"
45 | handleClose={close}
46 | handleConfirm={confirm}
47 | />,
48 | );
49 | } else {
50 | navigate('/');
51 | }
52 | };
53 |
54 | return (
55 | <>
56 |
57 |
58 |
59 |
60 | {petitionData.content.length >= 100 &&
61 | petitionData.title.length >= 2 ? (
62 | setIsOpenCheckPetitionModal(true)}
66 | />
67 | ) : (
68 |
69 | )}
70 |
71 |
72 |
73 |
80 |
85 |
86 |
87 | setIsOpenCheckPetitionModal(false)}
90 | isOpenCheckWriteModal={isOpenCheckPetitionModal}
91 | />
92 | >
93 | );
94 | };
95 |
96 | export default WritePetition;
97 |
--------------------------------------------------------------------------------
/src/pages/PetitionDetail/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 PetitionDetailLayout = styled.div`
6 | display: flex;
7 | justify-content: center;
8 | width: 100vw;
9 | height: 100vh;
10 | min-height: 100vh;
11 | background-color: ${color.white};
12 | `;
13 |
14 | export const PetitionDetailWrap = styled.div`
15 | display: flex;
16 | flex-direction: column;
17 | align-items: center;
18 | width: 56.6%;
19 | height: 100%;
20 | `;
21 |
22 | export const InfoBox = styled.div`
23 | display: flex;
24 | flex-direction: column;
25 | justify-content: center;
26 | width: 100%;
27 | margin-bottom: 30px;
28 | `;
29 |
30 | export const InfoWrap = styled.div`
31 | display: flex;
32 | justify-content: space-between;
33 | align-items: center;
34 | width: 100%;
35 | padding-bottom: 10px;
36 | border-bottom: 1px solid ${color.gray200};
37 | `;
38 |
39 | export const Progress = styled.div`
40 | ${font.p3}
41 | display: flex;
42 | justify-content: center;
43 | align-items: center;
44 | width: 56px;
45 | height: 30px;
46 | border: 1px solid ${(props) => props.color};
47 | border-radius: 50px;
48 | color: ${(props) => props.color};
49 | `;
50 |
51 | export const ItemsBox = styled.div`
52 | display: flex;
53 | flex-direction: column;
54 | gap: 16px;
55 | width: 100%;
56 | `;
57 |
58 | export const Title = styled.p`
59 | ${font.H1}
60 | color: ${color.gray900};
61 | max-width: 80%;
62 | text-overflow: ellipsis;
63 | overflow: hidden;
64 | word-break: break-word;
65 | display: -webkit-box;
66 | -webkit-line-clamp: 2;
67 | -webkit-box-orient: vertical;
68 | `;
69 |
70 | export const ItemRowWrap = styled.div`
71 | display: flex;
72 | align-items: center;
73 | gap: 15px;
74 | `;
75 |
76 | export const PetitionInfo = styled.div`
77 | display: flex;
78 | align-items: center;
79 | color: ${color.gray500};
80 | gap: 8px;
81 | `;
82 |
83 | export const Date = styled.p`
84 | ${font.p2}
85 | color: ${color.gray500};
86 | `;
87 |
88 | export const Email = styled.p`
89 | ${font.p2}
90 | color: ${color.gray500};
91 | `;
92 |
93 | export const ContentBox = styled.div`
94 | width: 100%;
95 | word-break: break-all;
96 | `;
97 |
98 | export const Content = styled.p`
99 | ${font.p2}
100 | white-space: pre-line;
101 | text-align: left;
102 | color: ${color.gray900};
103 | min-height: 200px;
104 | `;
105 |
106 | export const ApproveButton = styled.button`
107 | display: flex;
108 | justify-content: center;
109 | align-items: center;
110 | padding: 12px 18px;
111 | background: ${color.main};
112 | border-radius: 8px;
113 | color: ${color.white};
114 | `;
115 |
116 | export const ApprovedButton = styled.button`
117 | ${font.H5}
118 | color: ${color.white};
119 | display: flex;
120 | justify-content: center;
121 | align-items: center;
122 | padding: 12px 18px;
123 | background: ${color.gray400};
124 | border-radius: 8px;
125 | color: ${color.white};
126 | cursor: default;
127 | `;
128 |
129 | export const CommentSendBox = styled.div`
130 | margin: 50px 0px;
131 | display: flex;
132 | flex-direction: column;
133 | align-items: flex-end;
134 | width: 100%;
135 | `;
136 |
137 | export const CommentSendInput = styled.textarea`
138 | ${font.p2}
139 | padding: 16px 12px;
140 | resize: none;
141 | width: 100%;
142 | height: 90px;
143 | border: 1px solid ${color.gray300};
144 | border-radius: 8px;
145 | color: ${color.gray900};
146 | &::placeholder {
147 | color: ${color.gray400};
148 | }
149 | `;
150 |
151 | export const CommentSendButton = styled.button`
152 | ${font.btn1}
153 | color: ${color.main};
154 | display: flex;
155 | justify-content: center;
156 | align-items: center;
157 | margin-top: 12px;
158 | border-radius: 8px;
159 | background-color: ${color.white};
160 | border: 1px solid ${color.main};
161 | color: ${color.main};
162 | padding: 10px 16px;
163 | `;
164 |
165 | export const CommentListBox = styled.div`
166 | display: flex;
167 | flex-direction: column;
168 | width: 100%;
169 | `;
170 |
171 | export const Delete = styled.p`
172 | ${font.p3}
173 | color: ${color.gray600};
174 | cursor: pointer;
175 | &:hover {
176 | text-decoration-line: underline;
177 | text-decoration-color: ${color.gray600};
178 | }
179 | `;
180 |
--------------------------------------------------------------------------------
/src/features/PetitionFeature.ts:
--------------------------------------------------------------------------------
1 | import {
2 | answerPetition,
3 | approvePetition,
4 | writeComment,
5 | deleteAnswer,
6 | deleteComment,
7 | deletePetition,
8 | getPetitionDetail,
9 | } from 'api/petition.api';
10 | import { useMutation, useQuery, useQueryClient } from 'react-query';
11 | import * as KEY from 'constants/key.constant';
12 | import { toast } from 'react-toastify';
13 | import { useNavigate } from 'react-router-dom';
14 | import { useModal } from 'hooks/useModal';
15 | import { Dispatch, SetStateAction } from 'react';
16 | import { PetitionDetailType } from 'types/petition.type';
17 | import { useUser } from 'hooks/useUser';
18 | import { petitionDetailData } from 'fixtures';
19 | import { AxiosError } from 'axios';
20 |
21 | /** 청원 상세 페이지 데이터 불러오기 */
22 | export const usePetitionDetail = (petitionId: number) => {
23 | const { user } = useUser();
24 |
25 | const { data, isLoading, isError } = useQuery(
26 | [KEY.PETITION, petitionId],
27 | () => getPetitionDetail(petitionId),
28 | {
29 | enabled: !!user.authority,
30 | },
31 | );
32 |
33 | return {
34 | isLoading,
35 | isError,
36 | data: data || petitionDetailData,
37 | };
38 | };
39 |
40 | /** 청원 동의하기 */
41 | export const useApprovePetitionMutation = (petitionId: number) => {
42 | const queryClient = useQueryClient();
43 |
44 | return useMutation(() => approvePetition(petitionId), {
45 | onSuccess: () => {
46 | toast.success('동의 완료');
47 | queryClient.invalidateQueries([KEY.PETITION]);
48 | },
49 | });
50 | };
51 |
52 | /** 청원 삭제하기 */
53 | export const useDeletePetitionMutation = (petitionId: number) => {
54 | const navigate = useNavigate();
55 | const queryClient = useQueryClient();
56 | const { closeModal } = useModal();
57 |
58 | return useMutation(() => deletePetition(petitionId), {
59 | onSuccess: () => {
60 | closeModal();
61 | toast.success('삭제 완료');
62 | queryClient.invalidateQueries([KEY.PETITION_LIST]);
63 | navigate('/');
64 | },
65 | });
66 | };
67 |
68 | interface CommentPropsType {
69 | petitionId: number;
70 | setComment: Dispatch>;
71 | comment: string;
72 | }
73 |
74 | /** 청원 댓글 쓰기 (학생) */
75 | export const useWriteCommentMutation = ({
76 | petitionId,
77 | setComment,
78 | comment,
79 | }: CommentPropsType) => {
80 | const queryClient = useQueryClient();
81 |
82 | return useMutation(
83 | () =>
84 | writeComment({
85 | comment,
86 | petitionId,
87 | }),
88 | {
89 | onSuccess: () => {
90 | setComment('');
91 | toast.success('작성 성공');
92 | queryClient.invalidateQueries([KEY.PETITION]);
93 | },
94 | onError: (error: AxiosError) => {
95 | if (error.status === 400)
96 | toast.error('크기가 2에서 500 사이여야 합니다');
97 | },
98 | },
99 | );
100 | };
101 |
102 | /** 청원 답변 쓰기 (학생회) */
103 | export const useWriteAnswerMutation = ({
104 | petitionId,
105 | setComment,
106 | comment,
107 | }: CommentPropsType) => {
108 | const queryClient = useQueryClient();
109 |
110 | return useMutation(
111 | () =>
112 | answerPetition({
113 | comment,
114 | petitionId,
115 | }),
116 | {
117 | onSuccess: () => {
118 | setComment('');
119 | toast.success('답변 성공');
120 | queryClient.invalidateQueries([KEY.PETITION]);
121 | },
122 | onError: (error: AxiosError) => {
123 | if (error.status === 400)
124 | toast.error('크기가 2에서 4000 사이여야 합니다');
125 | },
126 | },
127 | );
128 | };
129 |
130 | /** 댓글 삭제 (학생) */
131 | export const useDeleteCommentMutation = (id: number) => {
132 | const queryClient = useQueryClient();
133 | const { closeModal } = useModal();
134 |
135 | return useMutation(() => deleteComment(id), {
136 | onSuccess: () => {
137 | toast.success('삭제 성공');
138 | queryClient.invalidateQueries([KEY.PETITION]);
139 | closeModal();
140 | },
141 | });
142 | };
143 |
144 | /** 답변 삭제 (학생회) */
145 | export const useDeleteAnswerMutation = (id: number) => {
146 | const queryClient = useQueryClient();
147 | const { closeModal } = useModal();
148 |
149 | return useMutation(() => deleteAnswer(id), {
150 | onSuccess: () => {
151 | toast.success('삭제 성공');
152 | queryClient.invalidateQueries([KEY.PETITION]);
153 | closeModal();
154 | },
155 | });
156 | };
157 |
--------------------------------------------------------------------------------
/src/pages/PetitionDetail/index.tsx:
--------------------------------------------------------------------------------
1 | import { ProgressChecker } from 'utils/ProgressChecker';
2 | import Progressbar from 'components/Progressbar';
3 | import { useParams } from 'react-router-dom';
4 | import { useState } from 'react';
5 | import { FormatDatetime } from 'utils/FormatDatetime';
6 | import Loading from 'components/common/Loading';
7 | import NotFound from 'pages/NotFound';
8 | import { useModal } from 'hooks/useModal';
9 | import Modal from 'components/common/Confirm';
10 | import { useUser } from 'hooks/useUser';
11 | import { EmailReplace } from 'utils/EmailReplace';
12 | import {
13 | useApprovePetitionMutation,
14 | useDeletePetitionMutation,
15 | useWriteCommentMutation,
16 | usePetitionDetail,
17 | useWriteAnswerMutation,
18 | } from 'features/PetitionFeature';
19 | import Answer from './Answer';
20 | import Comment from './Comment';
21 | import * as S from './style';
22 |
23 | const PetitionDetail = () => {
24 | const { openModal, closeModal } = useModal();
25 |
26 | const { id } = useParams();
27 | const petitionId = Number(id);
28 | const { user } = useUser();
29 | const [comment, setComment] = useState('');
30 |
31 | const { isLoading, isError, data } = usePetitionDetail(petitionId);
32 | const approveMutate = useApprovePetitionMutation(petitionId);
33 | const writeCommentMutate = useWriteCommentMutation({
34 | petitionId,
35 | setComment,
36 | comment,
37 | });
38 | const writeAnswerMutate = useWriteAnswerMutation({
39 | petitionId,
40 | setComment,
41 | comment,
42 | });
43 | const deletePetitionMutate = useDeletePetitionMutation(petitionId);
44 |
45 | const { color, progress } = ProgressChecker(data.status);
46 | const { date } = FormatDatetime(data.createdAt);
47 | const { userEmail } = EmailReplace(data.writer.email);
48 |
49 | const checkDeletePetition = () => {
50 | openModal(
51 | 정말 청원을 삭제 하시겠습니까?}
54 | closeText="취소"
55 | confirmText="삭제"
56 | handleClose={closeModal}
57 | handleConfirm={() => deletePetitionMutate.mutate()}
58 | />,
59 | );
60 | };
61 |
62 | if (isLoading) return ;
63 | if (isError) return ;
64 | return (
65 |
66 |
67 |
68 |
69 |
70 | {progress}
71 | {data.title}
72 |
73 | {date}|
74 |
75 | {data.writer.name} #{userEmail}
76 |
77 |
78 | {data.hasPermission && (
79 | 삭제
80 | )}
81 |
82 |
89 |
90 |
91 |
92 | {data.content}
93 |
94 | {user.authority === 'ROLE_STUDENT_COUNCIL' ||
95 | data.hasPermission ? null : data.approved ? (
96 | 동의 완료
97 | ) : (
98 | approveMutate.mutate()}>
99 | 동의하기
100 |
101 | )}
102 |
103 | setComment(e.target.value)}
111 | />
112 | {user.authority === 'ROLE_STUDENT_COUNCIL' ? (
113 | writeAnswerMutate.mutate()}>
114 | 답변 작성
115 |
116 | ) : (
117 | writeCommentMutate.mutate()}>
118 | 댓글 작성
119 |
120 | )}
121 |
122 |
123 | {data.answer?.map((item) => (
124 |
131 | ))}
132 | {data.comments?.map((item) => (
133 |
141 | ))}
142 |
143 |
144 |
145 | );
146 | };
147 |
148 | export default PetitionDetail;
149 |
--------------------------------------------------------------------------------
/src/assets/google.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------