diff --git a/index.html b/index.html index 24a65e6..bf8bd93 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,11 @@ as="image" href="/src/assets/image/bg-vote-promise.png" /> + diff --git a/package.json b/package.json index 07b5b07..72443ad 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@lottiefiles/dotlottie-react": "^0.14.2", "@stackflow/core": "^1.2.0", "@stackflow/plugin-basic-ui": "^1.14.1", "@stackflow/plugin-renderer-basic": "^1.1.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dff9fb0..a2a65f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@lottiefiles/dotlottie-react': + specifier: ^0.14.2 + version: 0.14.2(react@19.1.0) '@stackflow/core': specifier: ^1.2.0 version: 1.2.0 @@ -361,6 +364,14 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@lottiefiles/dotlottie-react@0.14.2': + resolution: {integrity: sha512-RR4r0HrKQbOAw6iS6C3mRARS2iu+yI+G1vICoUsRMHzlUUk1/26l3WyAjhcG+KoaGoKmORx8FgHjTNr4Sr/2Ug==} + peerDependencies: + react: ^17 || ^18 || ^19 + + '@lottiefiles/dotlottie-web@0.47.0': + resolution: {integrity: sha512-YN6wSB4iYZBYEAFKEs/taufrPH3rfNlUA632Ib61WoR58TALAJ1ZX8yDIGUBT28byMJhZR4+xdpRX4v7X8OeBQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1970,6 +1981,13 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@lottiefiles/dotlottie-react@0.14.2(react@19.1.0)': + dependencies: + '@lottiefiles/dotlottie-web': 0.47.0 + react: 19.1.0 + + '@lottiefiles/dotlottie-web@0.47.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/src/app/stackflow/Stack.tsx b/src/app/stackflow/Stack.tsx index 62e29c3..b2dec08 100644 --- a/src/app/stackflow/Stack.tsx +++ b/src/app/stackflow/Stack.tsx @@ -14,6 +14,7 @@ import { NoticeCreateScreen } from '@/screen/notice-create/ui'; import { NoticeScreen } from '@/screen/notice/ui'; import { VoteCompleteScreen } from '@/screen/vote-complete/ui'; import { VoteCreateCompleteScreen } from '@/screen/vote-create-complete/ui'; +import { VoteCreateLoadingScreen } from '@/screen/vote-create-loading/ui'; import { VoteCreateScreen } from '@/screen/vote-create/ui'; import { VoteEditScreen } from '@/screen/vote-edit/ui'; import { VotePromiseScreen } from '@/screen/vote-promise/ui'; @@ -37,6 +38,7 @@ export const { Stack, useFlow } = stackflow({ VoteEditScreen, VoteScreen, VoteCreateScreen, + VoteCreateLoadingScreen, VoteCreateCompleteScreen, VoteCompleteScreen, VotePromiseScreen, diff --git a/src/assets/icon/icon-user-square.svg b/src/assets/icon/icon-user-square.svg new file mode 100644 index 0000000..7c81cc3 --- /dev/null +++ b/src/assets/icon/icon-user-square.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/index.ts b/src/assets/icon/index.ts index e022a39..d25f58e 100644 --- a/src/assets/icon/index.ts +++ b/src/assets/icon/index.ts @@ -16,6 +16,7 @@ import NotificationIcon from './icon-notification.png'; import ResultIcon from './icon-result.png'; import TextBoxIcon from './icon-textbox.svg'; import UserIcon from './icon-user.svg'; +import UserSquareIcon from './icon-user-square.svg'; import VoteCompleteIcon from './icon-vote-complete.png'; import VoteIcon from './icon-vote.svg'; import WriteIcon from './icon-write.svg'; @@ -38,6 +39,7 @@ export { ResultIcon, TextBoxIcon, UserIcon, + UserSquareIcon, VerifiedCheckIcon, VoteCompleteIcon, VoteIcon, diff --git a/src/features/admin-dashboard/api/election.ts b/src/features/admin-dashboard/api/election.ts new file mode 100644 index 0000000..c9672f7 --- /dev/null +++ b/src/features/admin-dashboard/api/election.ts @@ -0,0 +1,19 @@ +import { REQUEST, userGet } from '@/shared/api'; +import type { Election, VoteStatus } from '@/shared/types'; +import { useQuery } from '@tanstack/react-query'; + +const fetchElectionByStatus = async (status: VoteStatus) => { + const response = await userGet({ + request: REQUEST.ELECTION_STATUS, + params: { status: status }, + }); + return response.data; +}; + +export const useFetchElectionByStatus = (status: VoteStatus) => { + return useQuery({ + queryKey: ['election-status', `${status}`], + queryFn: () => fetchElectionByStatus(status), + staleTime: 60 * 5, + }); +}; diff --git a/src/features/admin-dashboard/api/index.ts b/src/features/admin-dashboard/api/index.ts new file mode 100644 index 0000000..71fe4e1 --- /dev/null +++ b/src/features/admin-dashboard/api/index.ts @@ -0,0 +1 @@ +export * from './election'; diff --git a/src/features/admin-dashboard/ui/CardList.tsx b/src/features/admin-dashboard/ui/CardList.tsx index 09adf57..1315a35 100644 --- a/src/features/admin-dashboard/ui/CardList.tsx +++ b/src/features/admin-dashboard/ui/CardList.tsx @@ -1,25 +1,52 @@ -import type { VoteStatus } from '@/shared/types'; -// import { Card } from '@/shared/ui'; -import { VOTE_MOCK } from '@/features/admin-dashboard/mock'; +import type { Election, VoteStatus } from '@/shared/types'; +import { Card } from '@/shared/ui'; +import { getDate } from '@/shared/utils'; -export default function CardList({ status }: { status: VoteStatus }) { - const data = VOTE_MOCK.filter( - ({ status: voteStatus }) => voteStatus === status, +import { useFetchElectionByStatus } from '@/features/admin-dashboard/api'; + +export default function CardList({ status }: { status: string }) { + const VOTE_STATUS: Record = { + 진행중: 'ongoing', + 예정: 'upcoming', + 종료: 'ended', + }; + const { data, isError, isFetching } = useFetchElectionByStatus( + VOTE_STATUS[status], ); - console.log(data); - + + const renderElection = (data: Election[]) => { + if (isError) + return ( + + 투표를 가져오던 중 오류가 발생했어요! + + ); + if (!isFetching && data.length === 0) + return ( + 투표가 없어요 + ); + if (data && data.length > 0) + return ( + <> + {data.map(({ id, campus, title, startAt, endAt }, index) => ( + + ))} + > + ); + return <>>; + }; + return ( -{/* {data.map(({ id, campus, status, title, date }, index) => ( - - ))} */} + {renderElection(data || [])} ); } diff --git a/src/features/notice-create/api/create.ts b/src/features/notice-create/api/create.ts new file mode 100644 index 0000000..5f9149a --- /dev/null +++ b/src/features/notice-create/api/create.ts @@ -0,0 +1,26 @@ +import { useFlow } from '@/app/stackflow'; +import { useMutation } from '@tanstack/react-query'; + +import { REQUEST, userPost } from '@/shared/api'; +import type { Replace, WholeCampus, Notice } from '@/shared/types'; + +const submitNotice = async (data: Replace) => { + const response = await userPost({ + request: REQUEST.NOTICE.slice(0, -1), + data: data, + }); + return response; +}; + +export const useSubmitNotice = () => { + const { pop } = useFlow(); + + return useMutation({ + mutationFn: (data: Replace) => + submitNotice(data), + onSuccess: () => { + alert('공지 등록 성공!'); + pop({ animate: false }); + }, + }); +}; diff --git a/src/features/notice-create/api/index.ts b/src/features/notice-create/api/index.ts new file mode 100644 index 0000000..1e03cce --- /dev/null +++ b/src/features/notice-create/api/index.ts @@ -0,0 +1 @@ +export * from './create'; diff --git a/src/features/notice-create/ui/NoticeForm.tsx b/src/features/notice-create/ui/NoticeForm.tsx index fcc492d..500805b 100644 --- a/src/features/notice-create/ui/NoticeForm.tsx +++ b/src/features/notice-create/ui/NoticeForm.tsx @@ -1,35 +1,108 @@ +import { useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; + +import type { + Campus, + Replace, + WholeCampus, + Notice, + NoticeType, +} from '@/shared/types'; +import { CAMPUS, NOTICE_TYPE } from '@/shared/constants'; +import { keys } from '@/shared/utils'; +import { Button } from '@/shared/ui'; + +import { useSubmitNotice } from '@/features/notice-create/api'; + import NoticeFormButton from './NoticeFormButton'; import NoticeFormInput from './NoticeFormInput'; import NoticeFormItem from './NoticeFormItem'; export default function NoticeForm() { + const { mutate } = useSubmitNotice(); + const [noticeType, setNoticeType] = useState(); + const [campus, setCampus] = useState([]); + const { register, handleSubmit, watch, setValue } = useForm< + Replace + >({ + defaultValues: { + startAt: '2025-06-27', + endAt: '2025-06-28', + }, + }); + const isFormValid = + watch('title') && + watch('campus') && + watch('content') && + watch('noticeType'); + + useEffect(() => { + const value = campus.length > 1 ? ('ALL' as const) : campus[0]; + setValue('campus', value); + }, [campus, setValue]); + return ( - - - - - - - - - - - - - - - - - ~ - - - - - - - - - - + mutate(data))}> + + + + {keys(NOTICE_TYPE).map(noti => ( + { + setNoticeType(noti); + setValue('noticeType', noti); + }} + /> + ))} + + + + + {keys(CAMPUS).map(cam => ( + { + setCampus(prev => { + if (!prev?.includes(cam)) return [cam, ...prev]; + else return prev.filter(c => c !== cam); + }); + }} + /> + ))} + + + + + + ~ + + + + + + + + + + + + + 작성 완료 + + + ); } diff --git a/src/features/notice-create/ui/NoticeFormButton.tsx b/src/features/notice-create/ui/NoticeFormButton.tsx index db0c58d..e273076 100644 --- a/src/features/notice-create/ui/NoticeFormButton.tsx +++ b/src/features/notice-create/ui/NoticeFormButton.tsx @@ -1,15 +1,26 @@ import { cn } from '@/shared/utils'; -import { useState } from 'react'; +import type { HTMLAttributes } from 'react'; -export default function NoticeFormButton({ label }: { label: string }) { - const [selected, setSelected] = useState(false); +interface NoticeFormButtonProps extends HTMLAttributes { + label: string; + selected: boolean; +} + +export default function NoticeFormButton({ + label, + selected, + onClick, + ...rest +}: NoticeFormButtonProps) { return ( setSelected(!selected)} + type="button" + onClick={onClick} className={cn( selected ? 'text-m border-m' : 'border-sl text-s', 'cursor-pointer rounded-md border-[1px] px-[14px] py-1 text-sm focus:outline-none', )} + {...rest} > {label} diff --git a/src/features/notice-create/ui/NoticeFormInput.tsx b/src/features/notice-create/ui/NoticeFormInput.tsx index c632ae3..ef574bc 100644 --- a/src/features/notice-create/ui/NoticeFormInput.tsx +++ b/src/features/notice-create/ui/NoticeFormInput.tsx @@ -1,5 +1,12 @@ -export default function NoticeFormInput() { +import type { HTMLAttributes } from 'react'; + +type NoticeFormInputProps = HTMLAttributes; + +export default function NoticeFormInput({ ...rest }: NoticeFormInputProps) { return ( - + ); } diff --git a/src/features/notice/api/notice.ts b/src/features/notice/api/notice.ts index 43089a9..5659902 100644 --- a/src/features/notice/api/notice.ts +++ b/src/features/notice/api/notice.ts @@ -1,8 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { REQUEST, userGet } from '@/shared/api'; -import type { Campus } from '@/shared/types'; -import type { NoticeList } from '../types'; +import type { Campus, NoticeList } from '@/shared/types'; const fetchNoticeByCampus = async (campus: Campus) => { const response = await userGet({ diff --git a/src/features/notice/constants/status.ts b/src/features/notice/constants/status.ts index dd5561b..387a303 100644 --- a/src/features/notice/constants/status.ts +++ b/src/features/notice/constants/status.ts @@ -1,4 +1,4 @@ -import type { NoticeListType } from '../types'; +import type { NoticeStatus } from '@/shared/types'; type StatusStyle = { label: string; @@ -6,7 +6,7 @@ type StatusStyle = { color: string; }; -export const NOTICE_STATUS: Record = { +export const NOTICE_STATUS: Record = { COMPLETED: { label: '종료', bgColor: '#F5F5F5', color: '#999' }, NOTIFY: { label: '알림', diff --git a/src/features/notice/types/index.ts b/src/features/notice/types/index.ts deleted file mode 100644 index 6351718..0000000 --- a/src/features/notice/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './notice'; diff --git a/src/features/notice/types/notice.ts b/src/features/notice/types/notice.ts deleted file mode 100644 index 0ea5c2c..0000000 --- a/src/features/notice/types/notice.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type NoticeType = 'UPCOMING' | 'NOTIFY'; - -export type NoticeListType = NoticeType | 'ONGOING' | 'COMPLETED'; - -export type Notice = { - id: number; - title: 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 f8e4d6c..337cdfc 100644 --- a/src/features/notice/ui/NoticeItem.tsx +++ b/src/features/notice/ui/NoticeItem.tsx @@ -1,5 +1,5 @@ import { MessageIcon, NotificationIcon } from '@/assets/icon'; -import type { NoticeList } from '../types'; +import type { NoticeList } from '@/shared/types'; import { useFlow } from '@/app/stackflow'; import { PATH } from '@/shared/constants'; @@ -21,7 +21,7 @@ export default function NoticeItem({ className="flex w-full flex-shrink-0 cursor-pointer items-center overflow-hidden py-6 focus:outline-none" onClick={() => push(PATH.NOTICE_CONTENT, { - notice: { id, title, startAt, endAt }, + notice: { id, title, startAt, endAt, noticeStatus }, }) } > diff --git a/src/features/notice/ui/NoticeList.tsx b/src/features/notice/ui/NoticeList.tsx index 0f53cfc..75ecfd4 100644 --- a/src/features/notice/ui/NoticeList.tsx +++ b/src/features/notice/ui/NoticeList.tsx @@ -1,8 +1,7 @@ -import type { Campus } from '@/shared/types'; +import type { Campus, NoticeList } 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'; diff --git a/src/features/vote-create/api/create.ts b/src/features/vote-create/api/create.ts new file mode 100644 index 0000000..c59f59c --- /dev/null +++ b/src/features/vote-create/api/create.ts @@ -0,0 +1,144 @@ +import { useMutation } from '@tanstack/react-query'; +import { REQUEST, userPost } from '@/shared/api'; +import type { + Candidate, + CandidatePost, + Election, + ElectionPost, + NomineePost, + PledgePost, +} from '@/shared/types'; +import type { BaseResponse } from '@/shared/types/response'; +import { useFlow } from '@/app/stackflow'; +import { PATH } from '@/shared/constants'; + +interface ElectionResponse extends BaseResponse { + results: Election[]; +} + +interface CandidateResponse extends BaseResponse { + results: Candidate[]; +} + +const submitElection = async (data: ElectionPost) => { + const response = await userPost({ + request: REQUEST.ELECTION, + data: data, + }); + return response.data; +}; + +const submitCandidate = async (data: CandidatePost, electionId: number) => { + const response = await userPost< + CandidatePost & Pick, + CandidateResponse + >({ + request: REQUEST.CANDIDATE, + data: { ...data, electionId: electionId }, + }); + return response.data; +}; + +const submitNominee = async (data: NomineePost, candidateId: number) => { + const response = await userPost({ + request: REQUEST.NOMINEE_POST + `${candidateId}`, + data: data, + }); + return response.data; +}; + +const submitPledge = async (data: PledgePost[], candidateId: number) => { + const response = await userPost({ + request: REQUEST.PLEDGE_POST + `${candidateId}`, + data: data, + }); + return response; +}; + +export const useSubmitElection = ( + candidates: CandidatePost[], + nominees: NomineePost[][], + pledges: string[][], +) => { + const { mutateAsync: submitCandidate1 } = useSubmitCandidate( + nominees[0], + pledges[0], + ); + const { mutateAsync: submitCandidate2 } = useSubmitCandidate( + nominees[1], + pledges[1], + ); + + return useMutation({ + mutationFn: (data: ElectionPost) => submitElection(data), + onSuccess: data => { + const electionId = data.results[0].id; + submitCandidate1({ + data: candidates[0], + electionId: electionId, + }).then(() => + submitCandidate2({ + data: candidates[1], + electionId: electionId, + }), + ); + }, + }); +}; + +export const useSubmitCandidate = ( + nominees: NomineePost[], + pledges: string[], +) => { + const { mutateAsync: submitNominee } = useSubmitNominee(); + const { mutate } = useSubmitPledge(); + + return useMutation({ + mutationFn: ({ + data, + electionId, + }: { + data: CandidatePost; + electionId: number; + }) => submitCandidate(data, electionId), + onSuccess: data => { + const candidateId = data.results[0].id; + submitNominee({ data: nominees[0], candidateId: candidateId }).then(() => + submitNominee({ data: nominees[1], candidateId: candidateId }).then( + () => + mutate({ + data: pledges.map(pledge => ({ description: pledge })), + candidateId: candidateId, + }), + ), + ); + }, + }); +}; + +export const useSubmitNominee = () => { + return useMutation({ + mutationFn: ({ + data, + candidateId, + }: { + data: NomineePost; + candidateId: number; + }) => submitNominee(data, candidateId), + }); +}; + +export const useSubmitPledge = () => { + const { replace } = useFlow(); + + return useMutation({ + mutationFn: ({ + data, + candidateId, + }: { + data: PledgePost[]; + candidateId: number; + }) => submitPledge(data, candidateId), + onSuccess: () => replace(PATH.VOTE_CREATE_COMPLETE, {}), + }); +}; diff --git a/src/features/vote-create/api/index.ts b/src/features/vote-create/api/index.ts new file mode 100644 index 0000000..1e03cce --- /dev/null +++ b/src/features/vote-create/api/index.ts @@ -0,0 +1 @@ +export * from './create'; diff --git a/src/features/vote-create/model/context/create.ts b/src/features/vote-create/model/context/create.ts index cc84bbe..61dc1ce 100644 --- a/src/features/vote-create/model/context/create.ts +++ b/src/features/vote-create/model/context/create.ts @@ -5,4 +5,16 @@ export const VoteCreateContext = createContext<{ setMode: Dispatch>; isCandidateMode: boolean; setIsCandidateMode: Dispatch>; + voteType: string; + setVoteType: Dispatch>; + candidateKey: 'candidate1' | 'candidate2'; + setCandidateKey: Dispatch>; + nomineeKey: 'nominee1' | 'nominee2'; + setNomineeKey: Dispatch>; + date: Date; + setDate: Dispatch>; + startDate: string | null; + setStartDate: Dispatch>; + endDate: string | null; + setEndDate: Dispatch>; } | null>(null); diff --git a/src/features/vote-create/model/hooks/index.ts b/src/features/vote-create/model/hooks/index.ts index 51c41d5..67d5489 100644 --- a/src/features/vote-create/model/hooks/index.ts +++ b/src/features/vote-create/model/hooks/index.ts @@ -1 +1,2 @@ export { default as useVoteCreateContext } from './useVoteCreateContext'; +export { default as useElectionFormMode } from './useElectionFormMode'; diff --git a/src/features/vote-create/model/hooks/useElectionFormMode.ts b/src/features/vote-create/model/hooks/useElectionFormMode.ts new file mode 100644 index 0000000..ffa298b --- /dev/null +++ b/src/features/vote-create/model/hooks/useElectionFormMode.ts @@ -0,0 +1,126 @@ +import React from 'react'; +import type { MouseEvent } from 'react'; +import type { ElectionAllData } from '@/features/vote-create/types'; +import type { + UseFormRegister, + UseFormWatch, + UseFormSetValue, +} from 'react-hook-form'; +import { useVoteCreateContext } from '@/features/vote-create/model'; +import { useFlow } from '@/app/stackflow'; +import { VoteDetailForm, VoteTitleForm } from '@/features/vote-create/ui'; +import { PATH } from '@/shared/constants'; + +interface useElectionFormModeProps { + register: UseFormRegister; + watch: UseFormWatch; + setValue: UseFormSetValue; + candidateKey?: 'candidate1' | 'candidate2'; +} + +export default function useElectionFormMode({ + register, + watch, + setValue, +}: useElectionFormModeProps) { + const { + mode, + setMode, + isCandidateMode, + setIsCandidateMode, + candidateKey, + nomineeKey, + } = useVoteCreateContext(); + const { pop, replace } = useFlow(); + + const onSubmitClick = (data: ElectionAllData) => { + replace(PATH.VOTE_CREATE_LOADING, { ...data }); + }; + + const handleBackClick = (e: MouseEvent) => { + e.preventDefault(); + if (isCandidateMode) { + setIsCandidateMode(false); + return; + } + if (mode === 0) pop(); + else setMode(prev => prev - 1); + }; + + const handleNextClick = () => { + if (isCandidateMode) { + setIsCandidateMode(false); + } else if (mode < 2) { + setMode(prev => prev + 1); + } else return; + }; + + const isNomineeValid = ( + candidateKey: 'candidate1' | 'candidate2', + nomineeKey: 'nominee1' | 'nominee2', + ) => { + return !!( + watch(`${candidateKey}.${nomineeKey}.name` as const) && + watch(`${candidateKey}.${nomineeKey}.studentId` as const) && + watch(`${candidateKey}.${nomineeKey}.college` as const) && + watch(`${candidateKey}.${nomineeKey}.department` as const) && + watch(`${candidateKey}.${nomineeKey}.description1` as const) + ); + }; + + const isCandidateValid = (candidateKey: 'candidate1' | 'candidate2') => { + return ( + isNomineeValid(candidateKey, 'nominee1') && + isNomineeValid(candidateKey, 'nominee2') && + watch(`${candidateKey}.info.description`) + ); + }; + + const MODE = [ + { + form: React.createElement(VoteTitleForm, { register, watch, setValue }), + isFormValid: + watch('election.title') && + watch('election.startAt') && + watch('election.endAt') && + watch('candidate1.info.name') && + watch('candidate2.info.name'), + }, + { + form: React.createElement(VoteDetailForm, { + teamName: watch('candidate1.info.name'), + watch, + register, + setValue, + }), + isFormValid: isCandidateValid('candidate1'), + }, + { + form: React.createElement(VoteDetailForm, { + teamName: watch('candidate2.info.name'), + watch, + register, + setValue, + }), + isFormValid: isCandidateValid('candidate2'), + }, + ]; + + const isFormValid = isCandidateMode + ? isNomineeValid(candidateKey, nomineeKey) + : MODE[mode]!.isFormValid; + const buttonIntent = isFormValid + ? ('gradient' as const) + : ('disabled' as const); + + return { + MODE: MODE[mode], + handleBackClick, + handleNextClick, + isFormValid, + buttonIntent, + isCandidateValid, + isNomineeValid, + onSubmitClick, + }; +} diff --git a/src/features/vote-create/model/provider/create.tsx b/src/features/vote-create/model/provider/create.tsx index 8d50101..afe0f96 100644 --- a/src/features/vote-create/model/provider/create.tsx +++ b/src/features/vote-create/model/provider/create.tsx @@ -9,6 +9,16 @@ export default function VoteCreateProvider({ }) { const [mode, setMode] = useState(0); const [isCandidateMode, setIsCandidateMode] = useState(false); + const [voteType, setVoteType] = useState(''); + const [candidateKey, setCandidateKey] = useState<'candidate1' | 'candidate2'>( + 'candidate1', + ); + const [nomineeKey, setNomineeKey] = useState<'nominee1' | 'nominee2'>( + 'nominee1', + ); + const [date, setDate] = useState(new Date()); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); return ( {children} diff --git a/src/features/vote-create/types/data.ts b/src/features/vote-create/types/data.ts new file mode 100644 index 0000000..b5194a8 --- /dev/null +++ b/src/features/vote-create/types/data.ts @@ -0,0 +1,31 @@ +import type { CandidatePost, ElectionPost, NomineePost } from '@/shared/types'; + +export type ElectionAllData = { + election: ElectionPost; + candidate1: { + info: CandidatePost; + nominee1: Omit & { + description1: string; + description2?: string; + description3?: string; + }; + nominee2: Omit & { + description1: string; + description2?: string; + description3?: string; + }; + }; + candidate2: { + info: CandidatePost; + nominee1: Omit & { + description1: string; + description2?: string; + description3?: string; + }; + nominee2: Omit & { + description1: string; + description2?: string; + description3?: string; + }; + }; +}; diff --git a/src/features/vote-create/types/index.ts b/src/features/vote-create/types/index.ts new file mode 100644 index 0000000..3707679 --- /dev/null +++ b/src/features/vote-create/types/index.ts @@ -0,0 +1 @@ +export * from './data'; diff --git a/src/features/vote-create/ui/VoteButton.tsx b/src/features/vote-create/ui/VoteButton.tsx index 5f3ac36..acc28aa 100644 --- a/src/features/vote-create/ui/VoteButton.tsx +++ b/src/features/vote-create/ui/VoteButton.tsx @@ -1,27 +1,58 @@ +import { cn } from '@/shared/utils'; import type { ButtonHTMLAttributes } from 'react'; -import { IoChevronDownSharp, IoChevronForwardSharp } from 'react-icons/io5'; +import { FaCheck } from 'react-icons/fa'; +import { + IoAdd, + IoChevronDownSharp, + IoChevronForwardSharp, +} from 'react-icons/io5'; interface VoteButtonProps extends ButtonHTMLAttributes { label: string; arrowDown?: boolean; + done?: boolean; + className?: string; + plus?: boolean; } export default function VoteButton({ + plus = false, + className, arrowDown = false, label, onClick, + done = false, + ...rest }: VoteButtonProps) { return ( {label} - {arrowDown ? ( - + + {done ? ( + + + ) : ( - + <> + {plus ? ( + + ) : arrowDown ? ( + + ) : ( + + )} + > )} ); diff --git a/src/features/vote-create/ui/VoteCandidateForm.tsx b/src/features/vote-create/ui/VoteCandidateForm.tsx index a4ce439..29935d6 100644 --- a/src/features/vote-create/ui/VoteCandidateForm.tsx +++ b/src/features/vote-create/ui/VoteCandidateForm.tsx @@ -1,22 +1,65 @@ +import type { UseFormRegister, UseFormWatch } from 'react-hook-form'; +import type { ElectionAllData } from '../types'; import VoteInput from './VoteInput'; +import { useVoteCreateContext } from '../model'; + +interface VoteDetailFormProps { + register: UseFormRegister; + watch: UseFormWatch; +} + +export default function VoteCandidateForm({ + register, + watch, +}: VoteDetailFormProps) { + const { candidateKey, nomineeKey } = useVoteCreateContext(); + const isMain = nomineeKey === 'nominee1'; + + const fields = [ + { + placeholder: `${isMain ? '정' : '부'}후보자 이름`, + field: 'name' as const, + }, + { + placeholder: '학번', + field: 'studentId' as const, + }, + { + placeholder: '소속 단과대', + field: 'college' as const, + }, + { + placeholder: '학과', + field: 'department' as const, + }, + { + placeholder: '후보자 약력1', + field: 'description1' as const, + }, + { + placeholder: '후보자 약력2', + field: 'description2' as const, + }, + { + placeholder: '후보자 약력3', + field: 'description3' as const, + }, + ]; -export default function VoteCandidateForm() { return ( <> - 정후보자 등록하기 + {isMain ? '정후보자' : '부후보자'} 등록하기 - - - - - - ( + - + ))} > ); diff --git a/src/features/vote-create/ui/VoteDetailForm.tsx b/src/features/vote-create/ui/VoteDetailForm.tsx index 0580b50..da2aae9 100644 --- a/src/features/vote-create/ui/VoteDetailForm.tsx +++ b/src/features/vote-create/ui/VoteDetailForm.tsx @@ -1,17 +1,67 @@ -import { useVoteCreateContext } from '../model'; +import type { + UseFormRegister, + UseFormSetValue, + UseFormWatch, +} from 'react-hook-form'; +import { useElectionFormMode, useVoteCreateContext } from '../model'; +import type { ElectionAllData } from '../types'; import { VoteButton, VoteCandidateForm, VoteInput } from '.'; +import { useEffect, useState } from 'react'; interface VoteDetailFormProps { teamName: string; + watch: UseFormWatch; + register: UseFormRegister; + setValue: UseFormSetValue; } -export default function VoteDetailForm({ teamName }: VoteDetailFormProps) { - const { isCandidateMode, setIsCandidateMode } = useVoteCreateContext(); +export default function VoteDetailForm({ + teamName, + watch, + register, + setValue, +}: VoteDetailFormProps) { + const { isCandidateMode, setIsCandidateMode, setNomineeKey, candidateKey } = + useVoteCreateContext(); + + const { isNomineeValid } = useElectionFormMode({ + register, + watch, + setValue, + }); + const [pledge, setPledge] = useState([]); + + useEffect(() => { + setPledge([]); + }, [candidateKey]); + + useEffect(() => { + if (pledge.length > 0) { + setValue(`${candidateKey}.info.description`, pledge.join('\n')); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pledge]); + + const handleCandidateButtonClick = (isMainCandidate: boolean) => { + setNomineeKey(isMainCandidate ? 'nominee1' : 'nominee2'); + setIsCandidateMode(true); + }; + + const candidateButtons = [ + { + label: '정후보자', + isMain: true, + }, + { + label: '부후보자', + isMain: false, + }, + ]; return ( <> {isCandidateMode ? ( - + ) : ( <> @@ -21,15 +71,40 @@ export default function VoteDetailForm({ teamName }: VoteDetailFormProps) { 후보자를 입력해주세요 + {candidateButtons.map(({ label, isMain: isMainCandidate }) => ( + handleCandidateButtonClick(isMainCandidate)} + done={isNomineeValid( + candidateKey, + isMainCandidate ? 'nominee1' : 'nominee2', + )} + /> + ))} + {pledge.map((item, index) => ( + { + setPledge(prev => { + const newPledge = [...prev]; + newPledge[index] = e.target.value; + return newPledge; + }); + }} + /> + ))} setIsCandidateMode(true)} - /> - setIsCandidateMode(true)} + plus + label={`공약 추가하기 ${pledge.length}/7`} + type="button" + className="text-s border-[#F2F3F5] bg-[#F2F3F5]" + onClick={() => { + if (pledge.length < 7) setPledge([...pledge, '']); + }} /> - > )} diff --git a/src/features/vote-create/ui/VoteInput.tsx b/src/features/vote-create/ui/VoteInput.tsx index 5ca9035..aa35119 100644 --- a/src/features/vote-create/ui/VoteInput.tsx +++ b/src/features/vote-create/ui/VoteInput.tsx @@ -1,18 +1,51 @@ -import type { InputHTMLAttributes } from 'react'; +import { cn } from '@/shared/utils'; +import { useState, type InputHTMLAttributes } from 'react'; +import { FaCheck } from 'react-icons/fa'; import { IoClose } from 'react-icons/io5'; -type VoteInputProps = InputHTMLAttributes; +type VoteInputProps = Omit, 'value'> & { + value?: string; +}; + +export default function VoteInput({ value, ...rest }: VoteInputProps) { + const [enterDone, setEnterDone] = useState(false); + + const handleComplete = () => { + if (value && value.trim().length > 0) { + setEnterDone(true); + } + }; -export default function VoteInput({ placeholder }: VoteInputProps) { return ( - + { + if (e.key === 'Enter') { + e.preventDefault(); + handleComplete(); + } + }} /> - - - + {enterDone ? ( + + + + ) : ( + + + + )} ); } diff --git a/src/features/vote-create/ui/VoteTitleForm.tsx b/src/features/vote-create/ui/VoteTitleForm.tsx index 7ca545b..6c2ea7a 100644 --- a/src/features/vote-create/ui/VoteTitleForm.tsx +++ b/src/features/vote-create/ui/VoteTitleForm.tsx @@ -1,19 +1,87 @@ import { VoteButton, VoteInput } from '@/features/vote-create/ui'; +import type { + UseFormRegister, + UseFormWatch, + UseFormSetValue, +} from 'react-hook-form'; +import type { ElectionAllData } from '../types'; +import { BOTTOM_SHEET } from '@/shared/constants'; +import { useBottomSheet } from '@/shared/hook'; +import { useVoteCreateContext } from '@/features/vote-create/model'; +import { useEffect } from 'react'; +import { getDate } from '@/shared/utils'; + +interface VoteTitleFormProps { + watch: UseFormWatch; + register: UseFormRegister; + setValue: UseFormSetValue; +} + +export default function VoteTitleForm({ + register, + watch, + setValue, +}: VoteTitleFormProps) { + const { openBottomSheet } = useBottomSheet(); + const { voteType, startDate, endDate } = useVoteCreateContext(); + const isVoteTypeDone = voteType !== ''; + const isDateDone = startDate !== null && endDate !== null; + + useEffect(() => { + if (voteType !== '') { + setValue('election.title', voteType); + } + }, [voteType, setValue]); + + useEffect(() => { + if (startDate !== null && endDate !== null) { + setValue('election.startAt', startDate); + setValue('election.endAt', endDate); + } + }, [startDate, endDate, setValue]); -export default function VoteTitleForm() { return ( - <> + 선거 정보 등록하기 - - - + { + openBottomSheet(BOTTOM_SHEET.VOTE_TYPE); + }} + done={isVoteTypeDone} + /> + { + openBottomSheet(BOTTOM_SHEET.DATE_PICKER); + }} + done={isDateDone} + /> 선거운동본부 등록하기 - - + + - > + ); } diff --git a/src/screen/admin-home/ui/AdminHomeScreen.tsx b/src/screen/admin-home/ui/AdminHomeScreen.tsx index a57e615..42f409c 100644 --- a/src/screen/admin-home/ui/AdminHomeScreen.tsx +++ b/src/screen/admin-home/ui/AdminHomeScreen.tsx @@ -2,6 +2,7 @@ import { useFlow } from '@/app/stackflow'; import { VoteBg } from '@/assets/image'; import { PATH } from '@/shared/constants'; import { AdminAppBar } from '@/shared/ui'; +import { logout } from '@/shared/utils'; import { AdminHomeContainer } from '@/widgets/admin-home/ui'; import { AppScreen } from '@stackflow/plugin-basic-ui'; @@ -13,8 +14,12 @@ export default function AdminHomeScreen() { preventSwipeBack backgroundImage={`url(${VoteBg})`} appBar={AdminAppBar( - () => replace(PATH.LOGIN, {}), + () => { + replace(PATH.LOGIN, {}, { animate: false }); + logout(); + }, () => push(PATH.NOTICE_CREATE, {}), + () => replace(PATH.HOME, {}), )} > diff --git a/src/screen/home/ui/HomeScreen.tsx b/src/screen/home/ui/HomeScreen.tsx index 7f967b8..76c0c48 100644 --- a/src/screen/home/ui/HomeScreen.tsx +++ b/src/screen/home/ui/HomeScreen.tsx @@ -5,6 +5,7 @@ import { HomeAppBar } from '@/shared/ui'; import { HomeContainer } from '@/widgets/home/ui'; import { useFlow } from '@/app/stackflow'; import { PATH } from '@/shared/constants'; +import { logout } from '@/shared/utils'; export default function HomeScreen() { const { replace } = useFlow(); @@ -14,7 +15,10 @@ export default function HomeScreen() { preventSwipeBack backgroundImage={`url(${HomeBg})`} appBar={HomeAppBar( - () => replace(PATH.LOGIN, {}), + () => { + replace(PATH.LOGIN, {}, { animate: false }); + logout(); + }, () => {}, )} > diff --git a/src/screen/notice-content/ui/NoticeContentScreen.tsx b/src/screen/notice-content/ui/NoticeContentScreen.tsx index 61e230e..ac3f692 100644 --- a/src/screen/notice-content/ui/NoticeContentScreen.tsx +++ b/src/screen/notice-content/ui/NoticeContentScreen.tsx @@ -1,13 +1,13 @@ import { AppScreen } from '@stackflow/plugin-basic-ui'; import { TitleAppBar } from '@/shared/ui'; -import type { Notice } from '@/features/notice/types'; +import type { NoticeList } from '@/shared/types'; import type { ActivityComponentType } from '@stackflow/react'; import { NoticeContentContainer } from '@/widgets/notice-content/ui'; const NoticeContentScreen: ActivityComponentType<{ - notice: Omit; -}> = ({ params }: { params: { notice: Omit } }) => { + notice: NoticeList; +}> = ({ params }: { params: { notice: NoticeList } }) => { const { title, id, startAt, endAt } = params.notice; return ( diff --git a/src/screen/vote-create-complete/ui/VoteCreateCompleteScreen.tsx b/src/screen/vote-create-complete/ui/VoteCreateCompleteScreen.tsx index 96d54bc..ce3b433 100644 --- a/src/screen/vote-create-complete/ui/VoteCreateCompleteScreen.tsx +++ b/src/screen/vote-create-complete/ui/VoteCreateCompleteScreen.tsx @@ -4,7 +4,7 @@ import { useFlow } from '@/app/stackflow'; import { Button, NoBackAppBar } from '@/shared/ui'; import { VoteBg } from '@/assets/image'; import { CreateCompleteIcon } from '@/assets/icon'; - +import { DotLottieReact } from '@lottiefiles/dotlottie-react'; export default function VoteCreateCompleteScreen() { const { pop } = useFlow(); @@ -22,6 +22,15 @@ export default function VoteCreateCompleteScreen() { 투표는 대시보드에서 수정 가능해요 + + + + pop({ animate: false })}> 홈으로 돌아가기 diff --git a/src/screen/vote-create-loading/ui/VoteCreateLoadingScreen.tsx b/src/screen/vote-create-loading/ui/VoteCreateLoadingScreen.tsx new file mode 100644 index 0000000..0b466c3 --- /dev/null +++ b/src/screen/vote-create-loading/ui/VoteCreateLoadingScreen.tsx @@ -0,0 +1,114 @@ +import { AppScreen } from '@stackflow/plugin-basic-ui'; +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; + +import { Loader } from '@/shared/ui'; +import type { ActivityComponentType } from '@stackflow/react'; +import type { ElectionAllData } from '@/features/vote-create/types'; +import { useSubmitElection } from '@/features/vote-create/api'; +import type { NomineePost } from '@/shared/types'; +import { VoteBg } from '@/assets/image'; + +const VoteCreateLoadingScreen: ActivityComponentType = ({ + params, +}: { + params: ElectionAllData; +}) => { + const getNomineeData = ( + nominee: Omit & { + description1: string; + description2?: string; + description3?: string; + }, + ): NomineePost => { + const description = `${nominee.description1}\n${nominee.description2 || ''}\n${nominee.description3 || ''}`; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { description1, description2, description3, ...rest } = nominee; + return { description: description, ...rest }; + }; + + const candidates = [params.candidate1.info, params.candidate2.info]; + const nominees = [ + [ + getNomineeData(params.candidate1.nominee1), + getNomineeData(params.candidate1.nominee2), + ], + [ + getNomineeData(params.candidate2.nominee1), + getNomineeData(params.candidate2.nominee2), + ], + ]; + const pledges = [ + params.candidate1.info.description.split('\n'), + params.candidate2.info.description.split('\n'), + ]; + + const { mutate: submitElection } = useSubmitElection( + candidates, + nominees, + pledges, + ); + + useEffect(() => { + submitElection({ + ...params.election, + title: `${params.election.startAt.slice(0, 4)}년도 ${params.election.title}`, + }); + }, [submitElection, params]); + + const letters = ['생', '성', '중', '.', '.', '.']; + + const [visibleIndex, setVisibleIndex] = useState(null); + + useEffect(() => { + let step = 0; + + const interval = setInterval(() => { + if (step < letters.length) { + setVisibleIndex(step); + } else if (step === letters.length) { + setVisibleIndex(null); + } else { + setVisibleIndex(-1); // 모두 사라짐 + } + step = (step + 1) % (letters.length + 2); // 순환 + }, 800); + + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + + + 투표 + {letters.map((char, i) => ( + + {char} + + ))} + + + + + ); +}; + +export default VoteCreateLoadingScreen; diff --git a/src/screen/vote-create-loading/ui/index.ts b/src/screen/vote-create-loading/ui/index.ts new file mode 100644 index 0000000..0dc14e8 --- /dev/null +++ b/src/screen/vote-create-loading/ui/index.ts @@ -0,0 +1 @@ +export { default as VoteCreateLoadingScreen } from './VoteCreateLoadingScreen'; diff --git a/src/shared/api/requests.ts b/src/shared/api/requests.ts index acf0a63..ffd1ab2 100644 --- a/src/shared/api/requests.ts +++ b/src/shared/api/requests.ts @@ -3,10 +3,15 @@ export const REQUEST = { LOGIN: '/auth/kakao', JOIN: '/auth/kakao/signup', REFRESH: '/auth/refresh', + ELECTION: '/api/elections/', ELECTION_ALL: '/api/elections/all', - NOTICE_CAMPUS: '/api/notices/notices/campus/', + ELECTION_STATUS: '/api/elections/status', NOTICE: '/api/notices/', - CANDIDATE: '/api/candidates/all/', + NOTICE_CAMPUS: '/api/notices/notices/campus/', + CANDIDATE: '/api/candidates/', + CANDIDATE_ALL: '/api/candidates/all/', + NOMINEE_POST: '/api/nominees/', NOMINEE: '/api/nominees/{candidateId}/all', PLEDGE: '/api/pledges/{candidateId}/all', + PLEDGE_POST: '/api/pledges/', }; diff --git a/src/shared/constants/bottomSheet.ts b/src/shared/constants/bottomSheet.ts index 2b3f46b..5257673 100644 --- a/src/shared/constants/bottomSheet.ts +++ b/src/shared/constants/bottomSheet.ts @@ -1,3 +1,5 @@ export const BOTTOM_SHEET = { VOTE_RESULT: 'VoteResultBottomSheet', + VOTE_TYPE: 'VoteTypeBottomSheet', + DATE_PICKER: 'DatePickerBottomSheet', } as const; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index fbd4605..6459150 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -1,3 +1,4 @@ export * from './path'; export * from './bottomSheet'; export * from './campus'; +export * from './notice'; diff --git a/src/shared/constants/notice.ts b/src/shared/constants/notice.ts new file mode 100644 index 0000000..a11d600 --- /dev/null +++ b/src/shared/constants/notice.ts @@ -0,0 +1,6 @@ +import type { NoticeType } from '@/shared/types'; + +export const NOTICE_TYPE: Record = { + ELECTION: '선거 공지', + NOTIFY: '알림', +}; diff --git a/src/shared/constants/path.ts b/src/shared/constants/path.ts index ae885b4..0c1d8c5 100644 --- a/src/shared/constants/path.ts +++ b/src/shared/constants/path.ts @@ -12,6 +12,7 @@ export const PATH = { VOTE: 'VoteScreen', VOTE_COMPLETE: 'VoteCompleteScreen', VOTE_CREATE: 'VoteCreateScreen', + VOTE_CREATE_LOADING: 'VoteCreateLoadingScreen', VOTE_CREATE_COMPLETE: 'VoteCreateCompleteScreen', VOTE_EDIT: 'VoteEditScreen', VOTE_PROMISE: 'VotePromiseScreen', diff --git a/src/shared/types/campus.ts b/src/shared/types/campus.ts index 40d9c74..784d71c 100644 --- a/src/shared/types/campus.ts +++ b/src/shared/types/campus.ts @@ -1 +1,3 @@ export type Campus = 'SUWON' | 'SEOUL'; + +export type WholeCampus = 'ALL' | Campus; diff --git a/src/shared/types/election.ts b/src/shared/types/election.ts index 96d169e..61e0489 100644 --- a/src/shared/types/election.ts +++ b/src/shared/types/election.ts @@ -12,6 +12,10 @@ export type Election = { collageMajorName: string; }; +export type ElectionPost = Omit & { + active: boolean; +}; + export type Candidate = { id: number; name: string; @@ -19,6 +23,13 @@ export type Candidate = { voteCount: number; }; +export type CandidatePost = Omit< + Candidate, + 'id' | 'voteCount' | 'electionId' +> & { + description: string; +}; + export type Nominee = { id: number; name: string; @@ -30,8 +41,12 @@ export type Nominee = { main: boolean; }; +export type NomineePost = Omit; + export type Pledge = { id: number; description: string; candidateId: number; }; + +export type PledgePost = Omit; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index eda329a..78cd548 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -4,3 +4,5 @@ export * from './election'; export * from './path'; export * from './user'; export * from './vote'; +export * from './util'; +export * from './notice'; diff --git a/src/shared/types/notice.ts b/src/shared/types/notice.ts new file mode 100644 index 0000000..5c48dc3 --- /dev/null +++ b/src/shared/types/notice.ts @@ -0,0 +1,18 @@ +import type { Campus } from '@/shared/types'; + +export type NoticeStatus = 'UPCOMING' | 'NOTIFY' | 'ONGOING' | 'COMPLETED'; +export type NoticeType = 'NOTIFY' | 'ELECTION'; + +export type Notice = { + title: string; + content: string; + noticeType: NoticeType; + campus: Campus; + startAt: string; + endAt: string; +}; + +export type NoticeList = Omit & { + noticeStatus: NoticeStatus; + id: number; +}; diff --git a/src/shared/types/response.ts b/src/shared/types/response.ts new file mode 100644 index 0000000..cdc52a6 --- /dev/null +++ b/src/shared/types/response.ts @@ -0,0 +1,9 @@ +export type BaseResponse = { + status: { + code: number; + message: string; + }; + metadata: { + resultCount: number; + }; +}; diff --git a/src/shared/types/util.ts b/src/shared/types/util.ts new file mode 100644 index 0000000..85a3758 --- /dev/null +++ b/src/shared/types/util.ts @@ -0,0 +1,3 @@ +export type Replace = Omit & { + [P in K]: NewType; +}; diff --git a/src/shared/types/vote.ts b/src/shared/types/vote.ts index 40fadc1..d82cc1c 100644 --- a/src/shared/types/vote.ts +++ b/src/shared/types/vote.ts @@ -1 +1 @@ -export type VoteStatus = '진행중' | '종료' | '예정'; +export type VoteStatus = 'ongoing' | 'ended' | 'upcoming'; diff --git a/src/shared/ui/AppBar.tsx b/src/shared/ui/AppBar.tsx index ec1cf6a..5875f25 100644 --- a/src/shared/ui/AppBar.tsx +++ b/src/shared/ui/AppBar.tsx @@ -1,5 +1,11 @@ /* eslint-disable react-refresh/only-export-components */ -import { BackButton, LogoutIcon, UserIcon, WriteIcon } from '@/assets/icon'; +import { + BackButton, + LogoutIcon, + UserIcon, + UserSquareIcon, + WriteIcon, +} from '@/assets/icon'; import { HomeBg, LoginBg, VoteBg } from '@/assets/image'; import type { MouseEvent } from 'react'; @@ -21,13 +27,13 @@ export const HomeAppBar = ( renderRight: () => ( <> @@ -39,6 +45,7 @@ export const HomeAppBar = ( export const AdminAppBar = ( onLogoutClick: () => void, onWriteClick: () => void, + onChangeModeClick: () => void, ) => ({ ...baseStyle, backgroundImage: `url(${VoteBg})`, @@ -46,17 +53,23 @@ export const AdminAppBar = ( renderRight: () => ( <> + + + > ), }); diff --git a/src/shared/ui/BottomSheet.tsx b/src/shared/ui/BottomSheet.tsx index ec471e9..9463713 100644 --- a/src/shared/ui/BottomSheet.tsx +++ b/src/shared/ui/BottomSheet.tsx @@ -22,7 +22,7 @@ export default function BottomSheet({ children, sheetKey }: BottomSheetProps) { /> { + closeBottomSheet(BOTTOM_SHEET.DATE_PICKER); + }; + + return ( + <> + {isOpen && ( + + + + + + 선거 기간을 선택해 주세요 + + + + 완료 + + + )} + > + ); +} + +const WEEK = ['일', '월', '화', '수', '목', '금', '토']; + +const Calendar = () => { + const { + date: contextDate, + setDate, + startDate, + endDate, + setStartDate, + setEndDate, + } = useVoteCreateContext(); + const date = contextDate ?? new Date(); + const currentYear = date.getFullYear(); + const currentMonth = date.getMonth(); + const today = new Date(); + + const dayCnt = new Date(currentYear, currentMonth + 1, 0).getDate(); + const firstDay = new Date(currentYear, currentMonth, 1); + const firstWeek = Array.from({ length: firstDay.getDay() }, () => ''); + const wholeDay = Array.from({ length: dayCnt }, (_, i) => i + 1); + const lastWeek = Array.from( + { + length: (7 - ((firstWeek.length + wholeDay.length) % 7)) % 7, + }, + () => '', + ); + const formattedDay = [...firstWeek, ...wholeDay, ...lastWeek] as ( + | number + | '' + )[]; + + // 주 단위로 분할 + const weeks: (number | '')[][] = []; + for (let i = 0; i < formattedDay.length; i += 7) { + weeks.push(formattedDay.slice(i, i + 7)); + } + + const handleDateClick = (clickedDate: Date) => { + const todayStart = new Date(today); + todayStart.setHours(0, 0, 0, 0); + if (clickedDate < todayStart) return; + const clickedDateStart = new Date(clickedDate); + clickedDateStart.setHours(0, 0, 0, 0); + const isoDate = clickedDateStart.toISOString(); + if (!startDate || (startDate && endDate)) { + setStartDate(isoDate); + setEndDate(null); + } else { + const startDateObj = new Date(startDate); + startDateObj.setHours(0, 0, 0, 0); + if (clickedDateStart < startDateObj) { + setStartDate(isoDate); + setEndDate(startDate); + } else { + setEndDate(isoDate); + } + } + }; + + const isDateInRange = (renderDate: Date) => { + if (!startDate || !endDate) return false; + const start = new Date(startDate); + const end = new Date(endDate); + const date = new Date(renderDate); + start.setHours(0, 0, 0, 0); + end.setHours(0, 0, 0, 0); + date.setHours(0, 0, 0, 0); + const currentMonthStart = new Date(currentYear, currentMonth, 1); + const currentMonthEnd = new Date(currentYear, currentMonth + 1, 0); + return ( + date >= start && + date <= end && + date >= currentMonthStart && + date <= currentMonthEnd + ); + }; + + const isStartDate = (renderDate: Date) => { + if (!startDate) return false; + const start = new Date(startDate); + const date = new Date(renderDate); + start.setHours(0, 0, 0, 0); + date.setHours(0, 0, 0, 0); + const currentMonthStart = new Date(currentYear, currentMonth, 1); + const currentMonthEnd = new Date(currentYear, currentMonth + 1, 0); + return ( + date.getTime() === start.getTime() && + date >= currentMonthStart && + date <= currentMonthEnd + ); + }; + + const isEndDate = (renderDate: Date) => { + if (!endDate) return false; + const end = new Date(endDate); + const date = new Date(renderDate); + end.setHours(0, 0, 0, 0); + date.setHours(0, 0, 0, 0); + const currentMonthStart = new Date(currentYear, currentMonth, 1); + const currentMonthEnd = new Date(currentYear, currentMonth + 1, 0); + return ( + date.getTime() === end.getTime() && + date >= currentMonthStart && + date <= currentMonthEnd + ); + }; + + return ( + + + { + const newDate = new Date(date); + newDate.setMonth(currentMonth - 1); + const todayStart = new Date(today); + todayStart.setHours(0, 0, 0, 0); + if (newDate < todayStart) setDate(today); + else setDate(newDate); + }} + > + + + {currentYear}년 {currentMonth + 1}월 + { + const newDate = new Date(date); + newDate.setMonth(currentMonth + 1); + setDate(newDate); + }} + > + + + + + {WEEK.map((w, i) => ( + + {w} + + ))} + + + {weeks.map((week, weekIdx) => ( + + {week.map((d, dayIdx) => { + if (!d || typeof d !== 'number') { + return ( + + + {d} + + + ); + } + const renderDate = new Date(currentYear, currentMonth, d); + const todayStart = new Date(today); + todayStart.setHours(0, 0, 0, 0); + const inRange = isDateInRange(renderDate); + if (renderDate < todayStart) { + return ( + + + {d} + + + ); + } else { + return ( + + handleDateClick(renderDate)} + > + {d} + + + ); + } + })} + + ))} + + + ); +}; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 5c224df..3ec4345 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -5,3 +5,4 @@ export { default as Card } from './Card'; export { default as DateBadge } from './DateBadge'; export { default as Input } from './Input'; export { default as Loader } from './Loader'; +export { default as DatePickerBottomSheet } from './DatePickerBottomSheet'; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index ead9d8c..1ff26e2 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -5,3 +5,4 @@ export * from './logout'; export * from './navigate'; export * from './string'; export * from './getDate'; +export * from './object'; diff --git a/src/shared/utils/object.ts b/src/shared/utils/object.ts new file mode 100644 index 0000000..dabf6d5 --- /dev/null +++ b/src/shared/utils/object.ts @@ -0,0 +1,2 @@ +export const keys = (obj: T): Array => + Object.keys(obj) as Array; diff --git a/src/widgets/admin-dashboard/ui/AdminDashboardContainer.tsx b/src/widgets/admin-dashboard/ui/AdminDashboardContainer.tsx index c0535f6..ee798b7 100644 --- a/src/widgets/admin-dashboard/ui/AdminDashboardContainer.tsx +++ b/src/widgets/admin-dashboard/ui/AdminDashboardContainer.tsx @@ -1,11 +1,10 @@ import { CardList } from '@/features/admin-dashboard/ui'; -import type { VoteStatus } from '@/shared/types'; import { cn } from '@/shared/utils'; import { useState } from 'react'; export default function AdminDashboardContainer() { const [selected, setSelected] = useState(0); - const STATUS: VoteStatus[] = ['진행중', '종료', '예정']; + const STATUS = ['진행중', '종료', '예정']; return ( diff --git a/src/widgets/notice-create/ui/NoticeCreateContainer.tsx b/src/widgets/notice-create/ui/NoticeCreateContainer.tsx index 1e2c0b9..0a986cb 100644 --- a/src/widgets/notice-create/ui/NoticeCreateContainer.tsx +++ b/src/widgets/notice-create/ui/NoticeCreateContainer.tsx @@ -1,21 +1,5 @@ -import { useFlow } from '@/app/stackflow'; import { NoticeForm } from '@/features/notice-create/ui'; -import { Button } from '@/shared/ui'; export default function NoticeCreateContainer() { - const { pop } = useFlow(); - return ( - <> - - - pop({ animate: false })} - > - 작성 완료 - - - > - ); + return ; } diff --git a/src/widgets/vote-create/ui/VoteCreateContainer.tsx b/src/widgets/vote-create/ui/VoteCreateContainer.tsx index 714f1a8..5152d21 100644 --- a/src/widgets/vote-create/ui/VoteCreateContainer.tsx +++ b/src/widgets/vote-create/ui/VoteCreateContainer.tsx @@ -1,73 +1,83 @@ -import type { MouseEvent } from 'react'; import { AppScreen } from '@stackflow/plugin-basic-ui'; - -import { useFlow } from '@/app/stackflow'; +import { useForm } from 'react-hook-form'; import { cn } from '@/shared/utils'; -import { Button, VoteCreateAppBar } from '@/shared/ui'; +import { Button, DatePickerBottomSheet, VoteCreateAppBar } from '@/shared/ui'; -import { useVoteCreateContext } from '@/features/vote-create/model'; -import { VoteDetailForm, VoteTitleForm } from '@/features/vote-create/ui'; -import { PATH } from '@/shared/constants'; +import { + useVoteCreateContext, + useElectionFormMode, +} from '@/features/vote-create/model'; +import type { ElectionAllData } from '@/features/vote-create/types'; -export default function VoteCreateContainer() { - const { mode, setMode, isCandidateMode, setIsCandidateMode } = - useVoteCreateContext(); - const { pop, replace } = useFlow(); +import VoteTypeBottomSheet from './VoteTypeBottomSheet'; +import { useEffect } from 'react'; - const handleBackClick = (e: MouseEvent) => { - e.preventDefault(); - if (isCandidateMode) { - setIsCandidateMode(false); - return; - } - if (mode === 0) pop(); - else setMode(prev => prev - 1); - }; +export default function VoteCreateContainer() { + const { mode, setCandidateKey } = useVoteCreateContext(); + const { register, watch, setValue, handleSubmit } = useForm({ + defaultValues: { + election: { + description: '투표에 참여해주세요!', + campus: 'SUWON', + collageMajorName: '소프트웨어경영대학', + active: true, + }, + candidate1: { nominee1: { main: true }, nominee2: { main: false } }, + candidate2: { nominee1: { main: true }, nominee2: { main: false } }, + }, + }); - const handleNextClick = () => { - if (mode < 2) setMode(prev => prev + 1); - else replace(PATH.VOTE_CREATE_COMPLETE, {}); - }; + const { + MODE, + handleBackClick, + handleNextClick, + isFormValid, + buttonIntent, + onSubmitClick, + } = useElectionFormMode({ register, watch, setValue }); - const MODE = [ - , - , - , - ]; + useEffect(() => { + if (mode !== 2) setCandidateKey('candidate1'); + else setCandidateKey('candidate2'); + }, [mode, setCandidateKey]); return ( - - - - - - {MODE[mode]} - - - - 임시저장 - - - 다음 - - - + <> + + + + + + + {MODE!.form} + + + + 임시저장 + + + 다음 + + + + + + + > ); } diff --git a/src/widgets/vote-create/ui/VoteTypeBottomSheet.tsx b/src/widgets/vote-create/ui/VoteTypeBottomSheet.tsx new file mode 100644 index 0000000..fec95ed --- /dev/null +++ b/src/widgets/vote-create/ui/VoteTypeBottomSheet.tsx @@ -0,0 +1,56 @@ +import { useBottomSheet } from '@/shared/hook'; +import { BottomSheet } from '@/shared/ui'; +import { BOTTOM_SHEET } from '@/shared/constants'; +import { cn } from '@/shared/utils'; +import { useVoteCreateContext } from '@/features/vote-create/model'; + +export default function DatePickerBottomSheet() { + const { voteType, setVoteType } = useVoteCreateContext(); + const { closeBottomSheet, bottomSheetState } = useBottomSheet(); + const { isOpen } = bottomSheetState(BOTTOM_SHEET.VOTE_TYPE); + const year = [ + '총학생회 선거', + '인문대학 선거', + '예술체육대학 선거', + '사회과학대학 선거', + '창의공과대학 선거', + '소프트웨어경영대학 선거', + '융합과학대학 선거', + '관광문화대학 선거', + ]; + + const handleSelectYear = (selectedType: string) => { + setVoteType(selectedType); + closeBottomSheet(BOTTOM_SHEET.VOTE_TYPE); + }; + + return ( + <> + {isOpen && ( + + + + + + 선거 유형을 선택해 주세요 + + + {year.map(y => ( + handleSelectYear(y)} + className={cn( + voteType === y && 'text-ml', + 'active:text-ml text-s focus:outline-none', + )} + > + {y} + + ))} + + + )} + > + ); +} diff --git a/src/widgets/vote-create/ui/index.ts b/src/widgets/vote-create/ui/index.ts index 97f5cba..335fa20 100644 --- a/src/widgets/vote-create/ui/index.ts +++ b/src/widgets/vote-create/ui/index.ts @@ -1 +1,2 @@ export { default as VoteCreateContainer } from './VoteCreateContainer'; +export { default as VoteTypeBottomSheet } from './VoteTypeBottomSheet'; diff --git a/src/widgets/vote/api/election.ts b/src/widgets/vote/api/election.ts index 20f6af0..699d846 100644 --- a/src/widgets/vote/api/election.ts +++ b/src/widgets/vote/api/election.ts @@ -15,7 +15,7 @@ interface ElectionDetailResponse { const fetchElectionDetail = async (id: number) => { const response = await userGet({ - request: REQUEST.CANDIDATE + `${id}`, + request: REQUEST.CANDIDATE_ALL + `${id}`, }); return response.data; }; diff --git a/tsconfig.app.json b/tsconfig.app.json index 4cb2e5d..bc6066b 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,7 +23,6 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true },
- 정후보자 등록하기 + {isMain ? '정후보자' : '부후보자'} 등록하기
후보자를 입력해주세요
선거 정보 등록하기
선거운동본부 등록하기
투표는 대시보드에서 수정 가능해요
+ 투표 + {letters.map((char, i) => ( + + {char} + + ))} +
+ 선거 기간을 선택해 주세요 +
+ 선거 유형을 선택해 주세요 +