diff --git a/package-lock.json b/package-lock.json index 7ecb2ee3..c3c668df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", + "react-icons": "^5.5.0", "react-router-dom": "^7.1.1", "react-textarea-autosize": "^8.5.7", "sanitize.css": "^13.0.0", @@ -13036,6 +13037,15 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index b785325d..a3bfc6eb 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", + "react-icons": "^5.5.0", "react-router-dom": "^7.1.1", "react-textarea-autosize": "^8.5.7", "sanitize.css": "^13.0.0", diff --git a/src/components/applyComponents/careersComponent/CareersComponent.tsx b/src/components/applyComponents/careersComponent/CareersComponent.tsx index 6f8ab5b7..c8a47e47 100644 --- a/src/components/applyComponents/careersComponent/CareersComponent.tsx +++ b/src/components/applyComponents/careersComponent/CareersComponent.tsx @@ -1,24 +1,19 @@ -import { - Control, - FieldArrayWithId, - UseFieldArrayAppend, -} from 'react-hook-form'; +import { Control, useFieldArray } from 'react-hook-form'; import { ApplySchemeType } from '../../../pages/apply/Apply'; import * as S from './CareersComponent.styled'; import CareerInput from './careersInputComponent/CareersComponentInput'; import { CAREER_INPUT } from '../../../constants/projectConstants'; interface CareersComponentProps { - fieldsCareers: FieldArrayWithId[]; - appendCareers: UseFieldArrayAppend; control: Control; } -const CareersComponent = ({ - fieldsCareers, - appendCareers, - control, -}: CareersComponentProps) => { +const CareersComponent = ({ control }: CareersComponentProps) => { + const { fields: fieldsCareers, append: appendCareers } = useFieldArray({ + name: 'careers', + control, + }); + return ( {fieldsCareers.map((field, index) => ( diff --git a/src/components/common/loadingSpinner/LoadingSpinner.stories.tsx b/src/components/common/loadingSpinner/LoadingSpinner.stories.tsx new file mode 100644 index 00000000..b6c0a624 --- /dev/null +++ b/src/components/common/loadingSpinner/LoadingSpinner.stories.tsx @@ -0,0 +1,13 @@ +import { Meta, StoryObj } from '@storybook/react'; +import LoadingSpinner from './LoadingSpinner'; + +const meta = { + title: 'Component/Common/LoadingSpinner', + component: LoadingSpinner, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git "a/src/components/projectFormComponents/\bstepComponent/StepComponent.styled.ts" "b/src/components/projectFormComponents/\bstepComponent/StepComponent.styled.ts" new file mode 100644 index 00000000..297ef448 --- /dev/null +++ "b/src/components/projectFormComponents/\bstepComponent/StepComponent.styled.ts" @@ -0,0 +1,47 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + position: relative; + display: flex; + align-items: center; + justify-content: space-evenly; + width: 100%; + min-height: 50px; + margin: 20px 0; +`; + +export const Line = styled.div` + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 2px; + background-color: ${({ theme }) => theme.color.primary}; + transform: translateY(-50%); + z-index: 0; +`; + +export const StepWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + margin-top: 20px; +`; + +export const Circle = styled.div<{ isActive: boolean }>` + z-index: 1; + width: 30px; + height: 30px; + border-radius: 50%; + background-color: ${({ theme }) => theme.color.white}; + border: 2px solid + ${({ isActive, theme }) => (isActive ? theme.color.primary : '#ccc')}; + align-items: center; + justify-content: center; +`; + +export const Label = styled.span` + color: #333; + font-size: ${({ theme }) => theme.heading.small.tabletFontSize}; + margin-top: 5px; +`; diff --git "a/src/components/projectFormComponents/\bstepComponent/StepComponent.tsx" "b/src/components/projectFormComponents/\bstepComponent/StepComponent.tsx" new file mode 100644 index 00000000..bdc9313f --- /dev/null +++ "b/src/components/projectFormComponents/\bstepComponent/StepComponent.tsx" @@ -0,0 +1,42 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import * as S from './StepComponent.styled'; +import { FaCheck } from 'react-icons/fa'; +import { IoClose } from 'react-icons/io5'; +import { StepProp } from '../../../hooks/useMultiStepForm'; + +type StepComponentProps = { + steps: StepProp[]; + currentStepIndex: number; + setCurrentStepIndex: Dispatch>; +}; + +const StepComponent: React.FC = ({ + steps, + currentStepIndex, + setCurrentStepIndex, +}) => { + const handleClick = (index: number) => { + setCurrentStepIndex(index); + }; + + return ( + + + {steps.map((step, index) => { + const isActive = index === currentStepIndex; + + return ( + + handleClick(index)} + > + {step.title} + + ); + })} + + ); +}; + +export default StepComponent; diff --git a/src/hooks/useMultiStepForm.ts b/src/hooks/useMultiStepForm.ts new file mode 100644 index 00000000..c4929746 --- /dev/null +++ b/src/hooks/useMultiStepForm.ts @@ -0,0 +1,33 @@ +import { useState, ReactElement } from 'react'; + +export type StepProp = { + title: string; + element: ReactElement; +}; + +const useMultiStepForm = (steps: StepProp[]) => { + const [currentStepIndex, setCurrentStepIndex] = useState(0); + + const prev = () => { + setCurrentStepIndex((index) => (index <= 0 ? 0 : index - 1)); + }; + + const next = () => { + setCurrentStepIndex((index) => + index >= steps.length - 1 ? index : index + 1 + ); + }; + + return { + currentStepIndex, + currentTitle: steps[currentStepIndex].title, + currentStep: steps[currentStepIndex].element, + isFirstStep: currentStepIndex === 0, + isLastStep: currentStepIndex === steps.length - 1, + prev, + next, + setCurrentStepIndex, + }; +}; + +export default useMultiStepForm; diff --git a/src/mock/applicant.ts b/src/mock/applicant.ts index 864a7bcd..4651315d 100644 --- a/src/mock/applicant.ts +++ b/src/mock/applicant.ts @@ -43,3 +43,12 @@ export const passNonPass = http.patch( ); } ); + +export const createApplicant = http.post( + `${import.meta.env.VITE_API_BASE_URL}/project/:projectId/applicant`, + () => { + return HttpResponse.json({ + status: 200, + }); + } +); diff --git a/src/mock/browser.ts b/src/mock/browser.ts index cac154c2..ea43a146 100644 --- a/src/mock/browser.ts +++ b/src/mock/browser.ts @@ -3,6 +3,7 @@ import { myProjectList, sendResult } from './manageProjectList'; import { applicantInfo, applicantList, + createApplicant, passNonPass, passNonPassList, } from './applicant'; @@ -23,6 +24,7 @@ import { fetchPositionTag, fetchSkillTag, } from './projectSearchFiltering'; +import { createProject } from './createProject.ts'; export const handlers = [ fetchProjectLists, @@ -47,6 +49,8 @@ export const handlers = [ passNonPassList, mypageEditProfile, login, + createApplicant, + createProject, ]; export const worker = setupWorker(...handlers); diff --git a/src/mock/createProject.ts b/src/mock/createProject.ts new file mode 100644 index 00000000..1ab0fc72 --- /dev/null +++ b/src/mock/createProject.ts @@ -0,0 +1,10 @@ +import { http, HttpResponse } from 'msw'; + +export const createProject = http.post( + `${import.meta.env.VITE_API_BASE_URL}/project`, + () => { + return HttpResponse.json({ + status: 200, + }); + } +); diff --git a/src/pages/apply/Apply.tsx b/src/pages/apply/Apply.tsx index f7bccd68..124e7200 100644 --- a/src/pages/apply/Apply.tsx +++ b/src/pages/apply/Apply.tsx @@ -1,6 +1,6 @@ import * as S from './Apply.styled'; import Input from '../../components/projectFormComponents/inputComponent/InputComponent'; -import { useFieldArray, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useParams } from 'react-router-dom'; @@ -84,11 +84,6 @@ const Apply = () => { if (userEmail) setValue('email', userEmail); }, [userEmail, setValue]); - const { fields: fieldsCareers, append: appendCareers } = useFieldArray({ - name: 'careers', - control, - }); - const handleSubmit = (data: ApplySchemeType) => { const formData: joinProject = { email: data.email, @@ -150,11 +145,7 @@ const Apply = () => { 경력사항 / 수상이력 - + theme.mediaQuery.tablet} { + padding: 20px; + } +`; + +export const Title = styled.h1` + font-size: 2.3rem; + font-weight: bold; + margin-bottom: 50px; + + @media ${({ theme }) => theme.mediaQuery.tablet} { + font-size: 1.8rem; + } +`; + +export const Subtitle = styled.h2` + font-size: 1.5rem; + color: ${({ theme }) => theme.color.primary}; + margin-bottom: 20px; + + @media ${({ theme }) => theme.mediaQuery.tablet} { + font-size: 1.4rem; + } +`; + +export const Dates = styled.p` + font-size: ${({ theme }) => theme.heading.small.fontSize}; + color: ${({ theme }) => theme.color.placeholder}; + margin-bottom: 50px; + + @media ${({ theme }) => theme.mediaQuery.tablet} { + font-size: ${({ theme }) => theme.heading.small.fontSize}; + } +`; + +export const StepContainer = styled.div` + margin-bottom: 20px; +`; + +export const StepButton = styled.div` + display: flex; + gap: 20px; +`; + +export const StepLabel = styled.div` + font-size: 1rem; + font-weight: bold; + color: ${({ theme }) => theme.color.black}; + margin-bottom: 10px; + + @media ${({ theme }) => theme.mediaQuery.tablet} { + font-size: 1.1rem; + } +`; + +export const StepWrapper = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; +`; + +export const SubmitButton = styled(Button)` + width: 100px; + padding: 15px; + margin: 0 auto; + cursor: pointer; +`; diff --git a/src/pages/apply/ApplyStep.tsx b/src/pages/apply/ApplyStep.tsx new file mode 100644 index 00000000..bb4f9e9b --- /dev/null +++ b/src/pages/apply/ApplyStep.tsx @@ -0,0 +1,234 @@ +import * as S from './ApplyStep.styled'; +import Input from '../../components/projectFormComponents/inputComponent/InputComponent'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useParams } from 'react-router-dom'; +import { formatDate } from '../../util/format'; +import { joinProject } from '../../models/joinProject'; +import useGetProjectData from '../../hooks/useJoinProject'; +import CareersComponent from '../../components/applyComponents/careersComponent/CareersComponent'; +import PhoneComponent from '../../components/applyComponents/phoneComponent/PhoneComponent'; +import LoadingSpinner from '../../components/common/loadingSpinner/LoadingSpinner'; +import Modal from '../../components/common/modal/Modal'; +import { useModal } from '../../hooks/useModal'; +import useApplyProject from '../../hooks/useApplyProject'; +import useAuthStore from '../../store/authStore'; +import { useEffect } from 'react'; +import useMultiStepForm from '../../hooks/useMultiStepForm'; +import StepComponent from '../../components/projectFormComponents/\bstepComponent/StepComponent'; +import Button from '../../components/common/Button/Button'; + +const ApplyScheme = z.object({ + email: z + .string() + .nonempty({ message: '이메일을 입력해주세요.' }) + .email({ message: '이메일 형식으로 입력해주세요.' }), + phone: z + .string({ message: '전화번호를 입력해주세요.' }) + .array() + .nonempty({ message: '전화번호를 입력해주세요.' }), + wantToSay: z.string().optional(), + careers: z + .array( + z + .object({ + name: z.string().nonempty({ message: '경력명을 입력해주세요.' }), + periodStart: z + .string() + .nonempty({ message: '시작 날짜를 입력해주세요.' }) + .refine((date) => !isNaN(Date.parse(date)), { + message: '유효한 날짜를 입력해주세요.', + }), + periodEnd: z + .string() + .nonempty({ message: '종료 날짜를 입력해주세요.' }) + .refine((date) => !isNaN(Date.parse(date)), { + message: '유효한 날짜를 입력해주세요.', + }), + role: z.string().nonempty({ message: '역할을 입력해주세요.' }), + }) + .refine( + (data) => new Date(data.periodStart) < new Date(data.periodEnd), + { + message: '시작 날짜는 종료 날짜보다 이전이어야 합니다.', + path: ['periodStart'], + } + ) + ) + .optional(), +}); + +export type ApplySchemeType = z.infer; + +const Apply = () => { + const { projectId } = useParams(); + const id = Number(projectId); + const { isOpen, handleModalOpen, handleModalClose, message } = useModal(); + const { data: projectData, isLoading, isFetching } = useGetProjectData(id); + const { applyProject } = useApplyProject({ id, handleModalOpen }); + const userEmail = useAuthStore((state) => state.userData?.email); + const { + handleSubmit: onSubmitHandler, + formState: { errors }, + control, + setValue, + trigger, + } = useForm({ + resolver: zodResolver(ApplyScheme), + defaultValues: { + email: '', + phone: [], + wantToSay: '', + careers: [], + }, + }); + + const stepFields: (keyof ApplySchemeType)[][] = [ + ['email'], + ['phone'], + ['wantToSay'], + ['careers'], + ]; + + const stepList = [ + { + title: '이메일', + element: ( + + ), + }, + { + title: '전화번호', + element: , + }, + { + title: '팀장에게 전하는 말', + element: ( + + ), + }, + { + title: '수상/이력 사항', + element: , + }, + ]; + + useEffect(() => { + if (userEmail) setValue('email', userEmail); + }, [userEmail, setValue]); + + const { + currentStepIndex, + currentTitle, + currentStep, + isLastStep, + prev, + next, + setCurrentStepIndex, + } = useMultiStepForm(stepList); + + const handleNextStep = async () => { + const fieldsToValidate = stepFields[currentStepIndex]; + const isValid = await trigger(fieldsToValidate); + if (isValid) { + next(); + } + }; + + const handleSubmit = (data: ApplySchemeType) => { + const formData: joinProject = { + email: data.email, + phoneNumber: `${data?.phone?.[0]}-${data?.phone?.[1]}-${data?.phone?.[2]}`, + message: data.wantToSay, + career: data.careers, + }; + applyProject(formData); + }; + + if (!projectData) { + return ( + + {message} + + ); + } + + if (isLoading) return ; + if (isFetching) return ; + + return ( + + 프로젝트 지원 + {projectData.title} + {`${formatDate(projectData.recruitmentStartDate)} ~ ${formatDate( + projectData?.recruitmentEndDate + )}`} + + + +
+ + {currentTitle} + + + {currentStepIndex !== stepList.length - 1 && ( + + )} + + + + {currentStep} + + {isLastStep && ( + + 지원 완료하기 + + )} +
+ + + {message} + +
+ ); +}; + +export default Apply; diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index e3fa0181..c7eee16f 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -20,7 +20,7 @@ const Layout = lazy(() => import('../components/common/layout/Layout')); const Home = lazy(() => import('../pages/home/Home')); const MyPage = lazy(() => import('../pages/mypage/MyPage')); const UserPage = lazy(() => import('../pages/userpage/UserPage')); -const Apply = lazy(() => import('../pages/apply/Apply')); +const Apply = lazy(() => import('../pages/apply/ApplyStep')); const CreateProject = lazy( () => import('../pages/createProject/CreateProject') @@ -103,11 +103,11 @@ const AppRoutes = () => { { path: `${ROUTES.apply}/:projectId`, element: ( - - - - - + // + + + + // ), }, {