diff --git a/package.json b/package.json index 64d8829..07b5b07 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "axios": "^1.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dayjs": "^1.11.13", "framer-motion": "^12.12.1", "jotai": "^2.12.4", "lodash": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4788923..dff9fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 framer-motion: specifier: ^12.12.1 version: 12.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -991,6 +994,9 @@ packages: resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -2579,6 +2585,8 @@ snapshots: d3-transition: 3.0.1(d3-selection@3.0.0) d3-zoom: 3.0.0 + dayjs@1.11.13: {} + debug@4.4.1: dependencies: ms: 2.1.3 diff --git a/src/app/stackflow/Stack.tsx b/src/app/stackflow/Stack.tsx index 9ca2207..62e29c3 100644 --- a/src/app/stackflow/Stack.tsx +++ b/src/app/stackflow/Stack.tsx @@ -52,12 +52,21 @@ export const { Stack, useFlow } = stackflow({ ], initialActivity: () => { if (fetchLoginStatus()) { - if (sessionStorage.getItem('userMode')) { - const mode = JSON.parse(sessionStorage.getItem('userMode')!).mode; + const userModeRaw = sessionStorage.getItem('userMode'); + if (userModeRaw) { + const mode = JSON.parse(userModeRaw).mode; if (mode === 'STUDENT') return 'HomeScreen'; - else if (mode === 'ADMIN') return 'AdminHomeScreen'; - else return 'LoginScreen'; - } else return 'LoginScreen'; - } else return 'LoginScreen'; + if (mode === 'ADMIN') return 'AdminHomeScreen'; + } + + const userInfoRaw = sessionStorage.getItem('userInfo'); + if (userInfoRaw) { + const role = JSON.parse(userInfoRaw).role; + if (role === 'ROLE_USER') return 'HomeScreen'; + if (role === 'ROLE_ADMIN') return 'AdminHomeScreen'; + } + } + + return 'LoginScreen'; }, }); diff --git a/src/assets/icon/icon-alert.png b/src/assets/icon/icon-alert.png new file mode 100644 index 0000000..e1a5112 Binary files /dev/null and b/src/assets/icon/icon-alert.png differ diff --git a/src/assets/icon/index.ts b/src/assets/icon/index.ts index 00c47ad..e022a39 100644 --- a/src/assets/icon/index.ts +++ b/src/assets/icon/index.ts @@ -1,3 +1,4 @@ +import Alert from './icon-alert.png'; import Character from './character.svg'; import CharacterComplete from './character-complete.png'; import CharacterFlat from './character-flat.png'; @@ -20,6 +21,7 @@ import VoteIcon from './icon-vote.svg'; import WriteIcon from './icon-write.svg'; export { + Alert, BackButton, Character, Character2, diff --git a/src/features/admin-dashboard/ui/CardList.tsx b/src/features/admin-dashboard/ui/CardList.tsx index 93a8f70..09adf57 100644 --- a/src/features/admin-dashboard/ui/CardList.tsx +++ b/src/features/admin-dashboard/ui/CardList.tsx @@ -1,14 +1,16 @@ import type { VoteStatus } from '@/shared/types'; -import { Card } from '@/shared/ui'; -import { VOTE_MOCK } from '../mock'; +// import { Card } from '@/shared/ui'; +import { VOTE_MOCK } from '@/features/admin-dashboard/mock'; export default function CardList({ status }: { status: VoteStatus }) { const data = VOTE_MOCK.filter( ({ status: voteStatus }) => voteStatus === status, ); + console.log(data); + return (
- {data.map(({ id, campus, status, title, date }, index) => ( +{/* {data.map(({ id, campus, status, title, date }, index) => ( - ))} + ))} */}
); } diff --git a/src/features/home/api/election.ts b/src/features/home/api/election.ts new file mode 100644 index 0000000..ce72875 --- /dev/null +++ b/src/features/home/api/election.ts @@ -0,0 +1,29 @@ +import { REQUEST, userGet } from '@/shared/api'; +import type { Election } from '@/shared/types'; +import { useQuery } from '@tanstack/react-query'; + +interface ElectionResponse { + status: { + code: number; + message: string; + }; + metadata: { + resultCount: number; + }; + results: Election[]; +} + +const fetchAllElections = async () => { + const response = await userGet({ + request: REQUEST.ELECTION_ALL, + }); + if (response.data.metadata.resultCount === 0) return []; + return response.data.results; +}; + +export const useFetchAllElections = () => { + return useQuery({ + queryKey: ['elections'], + queryFn: fetchAllElections, + }); +}; diff --git a/src/features/home/api/index.ts b/src/features/home/api/index.ts new file mode 100644 index 0000000..71fe4e1 --- /dev/null +++ b/src/features/home/api/index.ts @@ -0,0 +1 @@ +export * from './election'; diff --git a/src/features/home/mock/card.ts b/src/features/home/mock/card.ts index 51cf3bc..d885edb 100644 --- a/src/features/home/mock/card.ts +++ b/src/features/home/mock/card.ts @@ -1,4 +1,10 @@ -import type { CardProps } from '@/features/home/types'; +type CardProps = { + id: number; + title: string; + campus: string; + status: string; + date: string; +} export const CARD_MOCK: CardProps[] = [ { diff --git a/src/features/home/types/card.ts b/src/features/home/types/card.ts deleted file mode 100644 index d4271cc..0000000 --- a/src/features/home/types/card.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type CardProps = { - id: number; - title: string; - campus: string; - status: string; - date: string; -}; diff --git a/src/features/home/types/index.ts b/src/features/home/types/index.ts deleted file mode 100644 index cb5809f..0000000 --- a/src/features/home/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './card'; diff --git a/src/features/home/ui/CardSection.tsx b/src/features/home/ui/CardSection.tsx new file mode 100644 index 0000000..22bc9a2 --- /dev/null +++ b/src/features/home/ui/CardSection.tsx @@ -0,0 +1,26 @@ +import { useFetchAllElections } from '../api'; +import CardStack from './CardStack'; + +export default function CardSection() { + const { data, isFetching, isError } = useFetchAllElections(); + if (isFetching) return <>; + if (isError) return <>; + if (data && data.length === 0) + return ( + <> +

진행중인 선거가 없어요

+
+
투표가 없어여
+
+ + ); + if (data) + return ( + <> +

진행중인 선거가 있어요!

+ + + ); + + return <>; +} diff --git a/src/features/home/ui/CardStack.tsx b/src/features/home/ui/CardStack.tsx index b1ce8fd..82a9adc 100644 --- a/src/features/home/ui/CardStack.tsx +++ b/src/features/home/ui/CardStack.tsx @@ -2,14 +2,14 @@ import { useRef, useState } from 'react'; import { animate, motion } from 'framer-motion'; import { Card } from '@/shared/ui'; -import type { CardProps } from '@/features/home/types'; -import { CARD_MOCK } from '@/features/home/mock'; +import type { Election } from '@/shared/types'; +import { getDate } from '@/shared/utils'; -export default function CardStack() { - const [stack, setStack] = useState(CARD_MOCK); - const [passedCards, setPassedCards] = useState([]); +export default function CardStack({ data }: { data: Election[] }) { + const [stack, setStack] = useState(data); + const [passedCards, setPassedCards] = useState([]); const cardRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}); - const currentIndex = CARD_MOCK.length - stack.length; + const currentIndex = data.length - stack.length; return ( <> @@ -47,7 +47,7 @@ export default function CardStack() { { x: 0 }, { type: 'spring', stiffness: 300 }, ); - setStack(CARD_MOCK); + setStack(data); setPassedCards([]); } else if (stack.length !== 0) { setPassedCards(prev => [...prev, stack[0]]); @@ -94,17 +94,17 @@ export default function CardStack() { > ); })}
- {CARD_MOCK.map((_, idx) => ( + {data.map((_, idx) => (
{ + const response = await userGet({ + request: REQUEST.NOTICE + `${id}`, + }); + return response.data; +}; + +export const useFetchNoticeContent = (id: number) => { + return useQuery({ + queryKey: [`${id}`, 'notice-content'], + queryFn: () => fetchNoticeContent(id), + }); +}; diff --git a/src/features/notice-content/api/index.ts b/src/features/notice-content/api/index.ts new file mode 100644 index 0000000..7b367d1 --- /dev/null +++ b/src/features/notice-content/api/index.ts @@ -0,0 +1 @@ +export * from './content'; diff --git a/src/features/notice-content/ui/NoticeContentSection.tsx b/src/features/notice-content/ui/NoticeContentSection.tsx new file mode 100644 index 0000000..e1fca77 --- /dev/null +++ b/src/features/notice-content/ui/NoticeContentSection.tsx @@ -0,0 +1,17 @@ +import { useFetchNoticeContent } from '../api'; + +export default function NoticeContentSection({ id }: { id: number }) { + const { data } = useFetchNoticeContent(id); + if (data) + return ( +
+ {data.content.split('\n').map((line, index) => ( +
+ {line} +
+ ))} +
+ ); + + return <>; +} diff --git a/src/features/notice-content/ui/index.ts b/src/features/notice-content/ui/index.ts new file mode 100644 index 0000000..45b01fe --- /dev/null +++ b/src/features/notice-content/ui/index.ts @@ -0,0 +1 @@ +export { default as NoticeContentSection } from './NoticeContentSection'; diff --git a/src/features/notice/mock/index.ts b/src/features/notice/api/index.ts similarity index 100% rename from src/features/notice/mock/index.ts rename to src/features/notice/api/index.ts diff --git a/src/features/notice/api/notice.ts b/src/features/notice/api/notice.ts new file mode 100644 index 0000000..43089a9 --- /dev/null +++ b/src/features/notice/api/notice.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; + +import { REQUEST, userGet } from '@/shared/api'; +import type { Campus } from '@/shared/types'; +import type { NoticeList } from '../types'; + +const fetchNoticeByCampus = async (campus: Campus) => { + const response = await userGet({ + request: REQUEST.NOTICE_CAMPUS + campus, + }); + return response.data; +}; + +export const useFetchNoticeByCampus = (campus: Campus) => { + return useQuery({ + queryKey: ['notices', campus], + queryFn: () => fetchNoticeByCampus(campus), + staleTime: 1000 * 60 * 5, + retry: 1, + }); +}; diff --git a/src/features/notice/constants/index.ts b/src/features/notice/constants/index.ts new file mode 100644 index 0000000..420cc02 --- /dev/null +++ b/src/features/notice/constants/index.ts @@ -0,0 +1 @@ +export * from './status'; diff --git a/src/features/notice/constants/status.ts b/src/features/notice/constants/status.ts new file mode 100644 index 0000000..dd5561b --- /dev/null +++ b/src/features/notice/constants/status.ts @@ -0,0 +1,22 @@ +import type { NoticeListType } from '../types'; + +type StatusStyle = { + label: string; + bgColor: string; + color: string; +}; + +export const NOTICE_STATUS: Record = { + COMPLETED: { label: '종료', bgColor: '#F5F5F5', color: '#999' }, + NOTIFY: { + label: '알림', + bgColor: 'rgba(255, 95, 95, 0.10)', + color: '#FA4545', + }, + ONGOING: { + label: '진행', + bgColor: 'rgba(50, 205, 50, 0.10)', + color: '#32CD32', + }, + UPCOMING: { label: '예정', bgColor: '#E9F6FF', color: '#377FF8' }, +}; diff --git a/src/features/notice/mock/notice.ts b/src/features/notice/mock/notice.ts deleted file mode 100644 index 9fb973b..0000000 --- a/src/features/notice/mock/notice.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Notice } from '../types'; - -export const NOTICE_MOCK: Notice[] = [ - { - id: 1, - title: '2025년 봄학기 개강 안내', - type: 'NOTIFICATION', - date: '2025-03-01', - }, - { - id: 2, - title: '홈페이지 점검 일정 안내', - type: 'NOTIFICATION', - date: '2025-03-05', - }, - { - id: 3, - title: '중간고사 일정 공지', - type: 'NOTIFICATION', - date: '2025-04-10', - }, - { - id: 4, - title: '신입생 오리엔테이션 안내', - type: 'NOTIFICATION', - date: '2025-02-25', - }, - { - id: 5, - title: '장학금 신청 기간 안내', - type: 'MESSAGE', - date: '2025-03-15', - }, - { - id: 6, - title: '건물 전기 점검에 따른 이용 제한 안내', - type: 'MESSAGE', - date: '2025-03-20', - }, -]; diff --git a/src/features/notice/types/notice.ts b/src/features/notice/types/notice.ts index c72e46b..0ea5c2c 100644 --- a/src/features/notice/types/notice.ts +++ b/src/features/notice/types/notice.ts @@ -1,8 +1,15 @@ -export type NoticeType = 'MESSAGE' | 'NOTIFICATION'; +export type NoticeType = 'UPCOMING' | 'NOTIFY'; + +export type NoticeListType = NoticeType | 'ONGOING' | 'COMPLETED'; export type Notice = { id: number; title: string; - type: NoticeType; - date: string; + startAt: string; + endAt: string; + noticeType: NoticeType; +}; + +export type NoticeList = Omit & { + noticeStatus: NoticeListType; }; diff --git a/src/features/notice/ui/NoticeItem.tsx b/src/features/notice/ui/NoticeItem.tsx index 901e71f..f8e4d6c 100644 --- a/src/features/notice/ui/NoticeItem.tsx +++ b/src/features/notice/ui/NoticeItem.tsx @@ -1,25 +1,33 @@ import { MessageIcon, NotificationIcon } from '@/assets/icon'; -import type { Notice } from '../types'; +import type { NoticeList } from '../types'; -import { cn } from '@/shared/utils'; import { useFlow } from '@/app/stackflow'; import { PATH } from '@/shared/constants'; +import { NOTICE_STATUS } from '../constants'; -export default function NoticeItem({ id, title, type, date }: Notice) { - const typeLabel = type === 'NOTIFICATION' ? '알림' : '예정'; +export default function NoticeItem({ + id, + title, + noticeStatus, + startAt, + endAt, +}: NoticeList) { const { push } = useFlow(); + const { label, bgColor, color } = NOTICE_STATUS[noticeStatus]; return (
-

{date}

+

{startAt}

); diff --git a/src/features/notice/ui/NoticeList.tsx b/src/features/notice/ui/NoticeList.tsx index e9f9e39..0f53cfc 100644 --- a/src/features/notice/ui/NoticeList.tsx +++ b/src/features/notice/ui/NoticeList.tsx @@ -1,12 +1,49 @@ -import { NOTICE_MOCK } from '../mock'; +import type { Campus } from '@/shared/types'; +import { Alert, CharacterFlat } from '@/assets/icon'; + +import { useFetchNoticeByCampus } from '@/features/notice/api'; +import type { NoticeList } from '@/features/notice/types'; + import NoticeItem from './NoticeItem'; -export default function NoticeList() { +export default function NoticeList({ campus }: { campus: Campus }) { + const { data, isError, isFetching } = useFetchNoticeByCampus(campus); + + const renderDataView = (data: NoticeList[]) => { + if (isFetching) return <>; + if (data.length === 0 && isError) return ; + if (!isFetching && !isError && data.length === 0) return ; + return ( + <> + {data.map(notice => ( + + ))} + + ); + }; + return (
- {NOTICE_MOCK.map(({ id, title, type, date }) => ( - - ))} + {renderDataView(data || [])}
); } + +const Error = () => ( +
+ + + 공지사항을 불러오지 못했어요 + + 인터넷 연결을 확인해주세요 +
+); + +const NoData = () => ( +
+ + + 아직 공지사항이 없어요! + +
+); diff --git a/src/screen/admin-vote-edit/ui/AdminVoteEditScreen.tsx b/src/screen/admin-vote-edit/ui/AdminVoteEditScreen.tsx index 5d5ce09..e18733d 100644 --- a/src/screen/admin-vote-edit/ui/AdminVoteEditScreen.tsx +++ b/src/screen/admin-vote-edit/ui/AdminVoteEditScreen.tsx @@ -18,7 +18,8 @@ export default function AdminVoteEditScreen() { 선거운동본부를 선택해주세요.

{ - if (data) { - console.log('로그인 성공', data); + const handleLogin = async () => { + if (!data) return; if (data.signedUp) { - const kakaoAccessToken = data.tokenDto.accessToken; - setUserToken({ - accessToken: kakaoAccessToken, - }); - replace(RAW_PATH.HOME); + try { + const kakaoAccessToken = data.tokenDto.accessToken; + await setUserToken({ accessToken: kakaoAccessToken }); + const userData = await fetchUserData(); + const userInfo = userData.data?.results?.[0]; + if (userInfo) { + sessionStorage.setItem('userInfo', JSON.stringify(userInfo)); + } + replace(RAW_PATH.HOME); + } catch (error) { + console.error('로그인 후 처리 중 오류', error); + alert('사용자 정보를 불러오는 데 실패했어요. 다시 시도해 주세요.'); + replace(RAW_PATH.HOME); + } } else { - const kakaoEmail = data.kakaoEmail; - setUserEmail({ - kakaoEmail: kakaoEmail, - }); + setUserEmail({ kakaoEmail: data.kakaoEmail }); alert('회원가입 화면으로 이동합니다!'); replace(RAW_PATH.SIGNUP); } - } + }; + + if (data) handleLogin(); if (isError) { alert('로그인에 실패했어요. 다시 시도해 주세요!'); replace(RAW_PATH.HOME); } - }, [data, isError, setUserToken, setUserEmail]); + }, [data, isError, setUserToken, setUserEmail, fetchUserData]); return (
diff --git a/src/screen/notice-content/ui/NoticeContentScreen.tsx b/src/screen/notice-content/ui/NoticeContentScreen.tsx index 6624d4c..61e230e 100644 --- a/src/screen/notice-content/ui/NoticeContentScreen.tsx +++ b/src/screen/notice-content/ui/NoticeContentScreen.tsx @@ -5,14 +5,17 @@ import type { Notice } from '@/features/notice/types'; import type { ActivityComponentType } from '@stackflow/react'; import { NoticeContentContainer } from '@/widgets/notice-content/ui'; -const NoticeContentScreen: ActivityComponentType<{ notice: Notice }> = ({ - params, -}: { - params: { notice: Notice }; -}) => { +const NoticeContentScreen: ActivityComponentType<{ + notice: Omit; +}> = ({ params }: { params: { notice: Omit } }) => { + const { title, id, startAt, endAt } = params.notice; return ( - + ); }; diff --git a/src/screen/vote-promise/ui/VotePromiseScreen.tsx b/src/screen/vote-promise/ui/VotePromiseScreen.tsx index 2644a6d..3219217 100644 --- a/src/screen/vote-promise/ui/VotePromiseScreen.tsx +++ b/src/screen/vote-promise/ui/VotePromiseScreen.tsx @@ -1,18 +1,30 @@ +import type { ActivityComponentType } from '@stackflow/react'; import { AppScreen } from '@stackflow/plugin-basic-ui'; import { TitleAppBar } from '@/shared/ui'; import { VotePromiseBg } from '@/assets/image'; import { VotePromiseContainer } from '@/widgets/vote-promise/ui'; +import type { Candidate, Nominee } from '@/shared/types'; -export default function VotePromiseScreen() { +const VotePledgeScreen: ActivityComponentType<{ + nominees: Array; + candidates: Array; +}> = ({ + params, +}: { + params: { nominees: Array; candidates: Array }; +}) => { return ( - <> - - - - + + + ); -} +}; + +export default VotePledgeScreen; diff --git a/src/screen/vote/ui/VoteScreen.tsx b/src/screen/vote/ui/VoteScreen.tsx index b7d3bed..ac44508 100644 --- a/src/screen/vote/ui/VoteScreen.tsx +++ b/src/screen/vote/ui/VoteScreen.tsx @@ -1,18 +1,22 @@ import { AppScreen } from '@stackflow/plugin-basic-ui'; +import type { ActivityComponentType } from '@stackflow/react'; import { TitleAppBar } from '@/shared/ui'; import { VoteBg } from '@/assets/image'; import { VoteContainer } from '@/widgets/vote/ui'; -export default function VoteScreen() { +const VoteScreen: ActivityComponentType<{ + id: number; + title: string; +}> = ({ params }: { params: { id: number; title: string } }) => { return ( - <> - - - - + + + ); -} +}; + +export default VoteScreen; diff --git a/src/shared/api/requests.ts b/src/shared/api/requests.ts index d78273c..acf0a63 100644 --- a/src/shared/api/requests.ts +++ b/src/shared/api/requests.ts @@ -1,5 +1,12 @@ export const REQUEST = { + USER: '/api/users/', LOGIN: '/auth/kakao', JOIN: '/auth/kakao/signup', REFRESH: '/auth/refresh', + ELECTION_ALL: '/api/elections/all', + NOTICE_CAMPUS: '/api/notices/notices/campus/', + NOTICE: '/api/notices/', + CANDIDATE: '/api/candidates/all/', + NOMINEE: '/api/nominees/{candidateId}/all', + PLEDGE: '/api/pledges/{candidateId}/all', }; diff --git a/src/shared/api/user.ts b/src/shared/api/user.ts index a23b461..c2f618e 100644 --- a/src/shared/api/user.ts +++ b/src/shared/api/user.ts @@ -27,7 +27,7 @@ type RefreshTokenResponse = { }; const instance = axios.create({ - baseURL: 'https://www.festamate.shop/api', + baseURL: 'http://localhost:8080', }); instance.interceptors.request.use(async config => { diff --git a/src/shared/constants/campus.ts b/src/shared/constants/campus.ts new file mode 100644 index 0000000..47cc9b2 --- /dev/null +++ b/src/shared/constants/campus.ts @@ -0,0 +1,6 @@ +import type { Campus } from '../types'; + +export const CAMPUS: Record = { + SUWON: '수원캠', + SEOUL: '서울캠', +}; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 395e34a..fbd4605 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -1,2 +1,3 @@ export * from './path'; export * from './bottomSheet'; +export * from './campus'; diff --git a/src/shared/hook/index.ts b/src/shared/hook/index.ts index 9004b34..42c0013 100644 --- a/src/shared/hook/index.ts +++ b/src/shared/hook/index.ts @@ -1 +1,2 @@ export { default as useBottomSheet } from './useBottomSheet'; +export * from './useFetchUserInfo'; diff --git a/src/shared/hook/useFetchUserInfo.ts b/src/shared/hook/useFetchUserInfo.ts new file mode 100644 index 0000000..c45c5a7 --- /dev/null +++ b/src/shared/hook/useFetchUserInfo.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query'; +import { REQUEST, userGet } from '../api'; +import { fetchLoginStatus } from '../utils'; +import type { Role } from '../types'; + +interface UserInfoResponse { + status: { + code: number; + message: string; + }; + metadata: { + resultCount: number; + }; + results: [ + { + id: 1; + name: string; + collegeMajorName: string; + kakaoEmail: string; + refreshToken: string; + role: Role; + studentVerified: boolean; + studentEmail: string; + walletAddress: string; + }, + ]; +} + +export const fetchUserInfo = async () => { + const response = await userGet({ + request: REQUEST.USER, + }); + return response.data; +}; + +export const useFetchUserInfo = () => { + return useQuery({ + queryKey: ['user-info'], + queryFn: fetchUserInfo, + retry: 1, + enabled: fetchLoginStatus(), + }); +}; diff --git a/src/shared/types/campus.ts b/src/shared/types/campus.ts new file mode 100644 index 0000000..40d9c74 --- /dev/null +++ b/src/shared/types/campus.ts @@ -0,0 +1 @@ +export type Campus = 'SUWON' | 'SEOUL'; diff --git a/src/shared/types/election.ts b/src/shared/types/election.ts new file mode 100644 index 0000000..96d169e --- /dev/null +++ b/src/shared/types/election.ts @@ -0,0 +1,37 @@ +import type { Campus } from './campus'; + +export type Election = { + id: number; + title: string; + description: string; + startAt: string; + endAt: string; + isActive: boolean; + campus: Campus; + ownerId: number; + collageMajorName: string; +}; + +export type Candidate = { + id: number; + name: string; + electionId: number; + voteCount: number; +}; + +export type Nominee = { + id: number; + name: string; + studentId: string; + college: string; + department: string; + description: string; + candidateId: number; + main: boolean; +}; + +export type Pledge = { + id: number; + description: string; + candidateId: number; +}; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 0433d04..eda329a 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1,4 +1,6 @@ -export * from './path'; export * from './bottomSheet'; -export * from './vote'; +export * from './campus'; +export * from './election'; +export * from './path'; export * from './user'; +export * from './vote'; diff --git a/src/shared/types/user.ts b/src/shared/types/user.ts index c5ea37a..c5bf0e7 100644 --- a/src/shared/types/user.ts +++ b/src/shared/types/user.ts @@ -14,3 +14,5 @@ export type Token = { accessTokenExpiresIn: number; refreshToken: string; }; + +export type Role = 'ROLE_ADMIN' | 'ROLE_USER'; diff --git a/src/shared/ui/Card.tsx b/src/shared/ui/Card.tsx index 653c228..8e634fb 100644 --- a/src/shared/ui/Card.tsx +++ b/src/shared/ui/Card.tsx @@ -1,17 +1,19 @@ +import dayjs from 'dayjs'; import { useFlow } from '@/app/stackflow'; -import { PATH } from '@/shared/constants'; +import { CAMPUS, PATH } from '@/shared/constants'; import { cn, hexToRgba } from '@/shared/utils'; import { Button, DateBadge } from '@/shared/ui'; -import type { PathItem } from '@/shared/types'; +import type { Campus, PathItem } from '@/shared/types'; interface CardProps { isStackCard?: boolean; className?: string; - campus: string; - status: string; + campus: Campus; + status?: string; title: string; date: string; + id: number; customTo?: PathItem; } @@ -19,10 +21,11 @@ export default function Card({ isStackCard = false, className, campus, - status, + status = '진행중', title, date, customTo, + id, }: CardProps) { const { push } = useFlow(); @@ -51,7 +54,7 @@ export default function Card({
- {campus} + {CAMPUS[campus]}
- {status === '진행중' && } + {status === '진행중' && ( + + )}

{title}

{date}

diff --git a/src/shared/utils/getDate.ts b/src/shared/utils/getDate.ts new file mode 100644 index 0000000..df175cb --- /dev/null +++ b/src/shared/utils/getDate.ts @@ -0,0 +1,11 @@ +import dayjs from 'dayjs'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import 'dayjs/locale/ko'; + +dayjs.extend(localizedFormat); +dayjs.locale('ko'); + +export const getDate = (date: Date | string, format: string) => { + const target = dayjs(date).locale('ko'); + return target.format(format); +}; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 4c27d5d..ead9d8c 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -4,3 +4,4 @@ export * from './fetchSessionData'; export * from './logout'; export * from './navigate'; export * from './string'; +export * from './getDate'; diff --git a/src/widgets/home/ui/HomeContainer.tsx b/src/widgets/home/ui/HomeContainer.tsx index 299e5b7..32aec40 100644 --- a/src/widgets/home/ui/HomeContainer.tsx +++ b/src/widgets/home/ui/HomeContainer.tsx @@ -2,7 +2,7 @@ import { NoticeIcon, ResultIcon } from '@/assets/icon'; import { useFlow } from '@/app/stackflow'; import type { PathItem } from '@/shared/types'; import { PATH } from '@/shared/constants'; -import { CardStack } from '@/features/home/ui'; +import { CardSection } from '@/features/home/ui'; interface ServiceButtonProps { title: string; @@ -14,8 +14,7 @@ interface ServiceButtonProps { export default function HomeContainer() { return (
-

진행중인 선거가 있어요!

- +

이런 서비스도 있어요

{title}

- 2025.03.19 + {date}


-
- {data.split('\n').map((line, index) => ( -
- {line} -
- ))} -
+
); } diff --git a/src/widgets/notice/ui/NoticeContainer.tsx b/src/widgets/notice/ui/NoticeContainer.tsx index 4925910..4f2d452 100644 --- a/src/widgets/notice/ui/NoticeContainer.tsx +++ b/src/widgets/notice/ui/NoticeContainer.tsx @@ -2,14 +2,18 @@ import { useState } from 'react'; import { cn } from '@/shared/utils'; import { NoticeList } from '@/features/notice/ui'; +import type { Campus } from '@/shared/types'; export default function NoticeContainer() { const [selected, setSelected] = useState(0); - const campus = ['수원캠', '서울캠']; + const CAMPUS: Record = { + 수원캠: 'SUWON', + 서울캠: 'SEOUL', + }; return (
- {campus.map((campusName, index) => ( + {Object.keys(CAMPUS).map((campusName, index) => (
- +
); } diff --git a/src/widgets/vote-promise/api/index.ts b/src/widgets/vote-promise/api/index.ts new file mode 100644 index 0000000..d6b79c1 --- /dev/null +++ b/src/widgets/vote-promise/api/index.ts @@ -0,0 +1 @@ +export * from './pledge'; diff --git a/src/widgets/vote-promise/api/pledge.ts b/src/widgets/vote-promise/api/pledge.ts new file mode 100644 index 0000000..c947566 --- /dev/null +++ b/src/widgets/vote-promise/api/pledge.ts @@ -0,0 +1,32 @@ +import { REQUEST, userGet } from '@/shared/api'; +import type { Pledge } from '@/shared/types'; +import { useQuery } from '@tanstack/react-query'; + +interface PledgeResponse { + status: { + code: number; + message: string; + }; + metadata: { + resultCount: number; + }; + results: Pledge[]; +} + +const fetchPledgeByCandidate = async (id: number) => { + const response = await userGet({ + request: REQUEST.PLEDGE.split('{candidateId}').join(`${id}`), + }); + return response.data; +}; + +export const useFetchPledgeData = (id: number) => { + return useQuery({ + queryKey: [`candidate-${id}-pledge`], + queryFn: () => fetchPledgeByCandidate(id), + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchInterval: false, + }); +}; diff --git a/src/widgets/vote-promise/ui/VoteCandidate.tsx b/src/widgets/vote-promise/ui/VoteCandidate.tsx index af4125b..185af77 100644 --- a/src/widgets/vote-promise/ui/VoteCandidate.tsx +++ b/src/widgets/vote-promise/ui/VoteCandidate.tsx @@ -1,13 +1,20 @@ -interface VoteCandidateProps { - name: string; -} -export default function VoteCandidate({ name }: VoteCandidateProps) { +import type { Nominee } from '@/shared/types'; + +export default function VoteCandidate({ + name, + main, + college, + department, + studentId, + description, +}: Nominee) { return ( - <> -

정후보자 {name}

-

컴퓨터공학부 04

-

기아기아 최강기아 역임

-

기아의 해결사 역임

- +
+

+ {main ? '정후보자' : '부후보자'} {name} +

+

{`${college} ${department} ${studentId.slice(2, 4)}`}

+

{description}

+
); } diff --git a/src/widgets/vote-promise/ui/VotePromiseCard.tsx b/src/widgets/vote-promise/ui/VotePromiseCard.tsx index fbbac6a..ac25661 100644 --- a/src/widgets/vote-promise/ui/VotePromiseCard.tsx +++ b/src/widgets/vote-promise/ui/VotePromiseCard.tsx @@ -2,15 +2,70 @@ import { useState } from 'react'; import { motion } from 'framer-motion'; import { VerifiedCheckIcon } from '@/assets/icon'; -import VoteCandidate from './VoteCandidate'; +import type { Candidate, Nominee, Pledge } from '@/shared/types'; import { cn } from '@/shared/utils'; -export default function VotePromiseCard() { +import VoteCandidate from './VoteCandidate'; +import { useFetchPledgeData } from '../api'; + +interface VotePromiseCardProps { + nominees: Nominee[]; + candidateName: string; + candidate: Candidate; +} + +export default function VotePromiseCard({ + nominees, + candidateName, + candidate, +}: VotePromiseCardProps) { const [flipped, setFlipped] = useState(false); + const { data: pledge } = useFetchPledgeData(candidate.id); + + const CardFront = () => ( +
+
+ + 1/2 +
+
+ {nominees.map(nominee => ( + + ))} +
+
+ ); + + const CardBack = () => ( +
+
+ + 2/2 +
+
+

+ '{candidateName}'의 핵심공약 +

+ {renderPledge(pledge?.results || [])} +
+
+ ); + + const renderPledge = (data: Pledge[]) => { + if (data) + return ( +
+ {data.map(({ id, description }) => ( + + ))} +
+ ); + else return <>; + }; return (
setFlipped(!flipped)} > ( -
-
- - 1/2 -
-
- -
-
- -
-
-); - -const CardBack = () => ( -
-
- - 2/2 -
-
-

- '기아'의 핵심공약 -

-
- - - - - -
-
-
-); - const TeamBadge = ({ team, flipped = false, diff --git a/src/widgets/vote-promise/ui/VotePromiseContainer.tsx b/src/widgets/vote-promise/ui/VotePromiseContainer.tsx index 8a45c2a..d6de9a1 100644 --- a/src/widgets/vote-promise/ui/VotePromiseContainer.tsx +++ b/src/widgets/vote-promise/ui/VotePromiseContainer.tsx @@ -3,18 +3,32 @@ import VotePromiseCard from './VotePromiseCard'; import { cn } from '@/shared/utils'; import { Button } from '@/shared/ui'; import { useFlow } from '@/app/stackflow'; +import type { Candidate, Nominee } from '@/shared/types'; -export default function VotePromiseContainer() { +interface VotePromiseContainerProps { + nomineeData: Array; + candidateData: Array; +} + +export default function VotePromiseContainer({ + nomineeData, + candidateData, +}: VotePromiseContainerProps) { const [selectedCard, setSelectedCard] = useState(0); const { pop } = useFlow(); const promises = Array.from({ length: 2 }); + const candidates = candidateData.map(candidate => candidate.name); return (

카드를 탭해서 뒤집어보세요!

- +
{promises.map((_, i) => ( @@ -24,7 +38,7 @@ export default function VotePromiseContainer() { selectedCard === i ? 'border-m bg-mxl text-m border-[1px]' : 'text-sl bg-white', - 'grid size-15 place-items-center rounded-lg text-3xl font-bold', + 'grid size-15 cursor-pointer place-items-center rounded-lg text-3xl font-bold', )} onClick={() => setSelectedCard(i)} > diff --git a/src/widgets/vote/api/candidate.ts b/src/widgets/vote/api/candidate.ts new file mode 100644 index 0000000..4ecc77e --- /dev/null +++ b/src/widgets/vote/api/candidate.ts @@ -0,0 +1,29 @@ +import { REQUEST, userGet } from '@/shared/api'; +import type { Candidate, Nominee } from '@/shared/types'; +import { useQuery } from '@tanstack/react-query'; + +interface CandidateDetailResponse { + status: { + code: number; + message: string; + }; + metadata: { + resultCount: number; + }; + results: Nominee[]; +} + +const fetchCandidateDetail = async (id: number) => { + const response = await userGet({ + request: REQUEST.NOMINEE.split('{candidateId}').join(`${id}`).toString(), + }); + return response.data; +}; + +export const useFetchCandidateDetail = (id?: number, candidate?: Candidate) => { + return useQuery({ + queryKey: ['candidate-detail', `${id}`], + queryFn: () => fetchCandidateDetail(id!), + enabled: typeof candidate !== 'undefined', + }); +}; diff --git a/src/widgets/vote/api/election.ts b/src/widgets/vote/api/election.ts new file mode 100644 index 0000000..20f6af0 --- /dev/null +++ b/src/widgets/vote/api/election.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { REQUEST, userGet } from '@/shared/api'; +import type { Candidate } from '@/shared/types'; + +interface ElectionDetailResponse { + status: { + code: number; + message: string; + }; + metadata: { + resultCount: number; + }; + results: Candidate[]; +} + +const fetchElectionDetail = async (id: number) => { + const response = await userGet({ + request: REQUEST.CANDIDATE + `${id}`, + }); + return response.data; +}; + +export const useFetchElectionDetail = (id: number) => { + return useQuery({ + queryKey: ['election-detail', `${id}`], + queryFn: () => fetchElectionDetail(id), + }); +}; diff --git a/src/widgets/vote/ui/VoteContainer.tsx b/src/widgets/vote/ui/VoteContainer.tsx index 8af7a9a..20b6e78 100644 --- a/src/widgets/vote/ui/VoteContainer.tsx +++ b/src/widgets/vote/ui/VoteContainer.tsx @@ -7,47 +7,81 @@ import { Button } from '@/shared/ui'; import { PATH } from '@/shared/constants'; import { VoteItem } from '@/widgets/vote/ui'; -import { VOTE_MOCK } from '@/widgets/vote/mock'; +import { useFetchElectionDetail } from '../api/election'; +import { useFetchCandidateDetail } from '../api/candidate'; -export default function VoteContainer() { +interface VoteContainerProps { + electionId: number; + title: string; +} + +export default function VoteContainer({ + electionId, + title, +}: VoteContainerProps) { const [selected, setSelected] = useState(-1); const { push, replace } = useFlow(); + const { data: candidates } = useFetchElectionDetail(electionId); + + const { data: nominee1 } = useFetchCandidateDetail( + candidates?.results[0].id, + candidates?.results[0], + ); + const { data: nominee2 } = useFetchCandidateDetail( + candidates?.results[1].id, + candidates?.results[1], + ); const handleClick = (index: number) => { if (selected === index) setSelected(-1); else setSelected(index); }; + const renderCandidates = () => { + if (candidates) { + return ( +
+ {candidates.results.map(({ name, id }, index) => ( + handleClick(index)} + /> + ))} +
+ VS +
+
+ ); + } else return <>; + }; + return (
-
-

2025학년도

-

총학생회 선거 후보

+
+ {title.split(' ')[0]} +
+ {title.split(' ').slice(1).join(' ') + ' 후보'}

공약을 살펴보고 신중하게 투표해주세요

-
- {VOTE_MOCK.map(({ title, candidates }, index) => ( - handleClick(index)} - /> - ))} -
- VS -
-
+ {renderCandidates()}
);