diff --git a/src/application/stores/neososeo-form.ts b/src/application/stores/neososeo-form.ts new file mode 100644 index 00000000..c8685548 --- /dev/null +++ b/src/application/stores/neososeo-form.ts @@ -0,0 +1,19 @@ +import { NeososeoAnswerData, NeososeoFormData } from '@api/types/neososeo-form'; +import { atom } from 'recoil'; + +export const neososeoFormState = atom({ + key: 'neososeoFormState', + default: null, +}); + +export const neososeoAnswerState = atom({ + key: 'neososeoAnswerState', + default: { + userID: 0, + formID: 0, + name: '', + relationID: 0, + answer: '', + keyword: [], + }, +}); diff --git a/src/application/utils/string.ts b/src/application/utils/string.ts new file mode 100644 index 00000000..a4d87892 --- /dev/null +++ b/src/application/utils/string.ts @@ -0,0 +1,2 @@ +export const isAllFilled = (...args: unknown[]) => + args.every((arg) => arg !== null && arg !== undefined && arg !== ''); diff --git a/src/assets/images/img_answerdone.svg b/src/assets/images/img_answerdone.svg new file mode 100644 index 00000000..9fd2fad7 --- /dev/null +++ b/src/assets/images/img_answerdone.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts index 98c8c8e6..4988ee99 100644 --- a/src/assets/images/index.ts +++ b/src/assets/images/index.ts @@ -2,3 +2,4 @@ export { default as imgLogo } from './img_logo.svg'; export { default as imgEmptyProfile } from './img_empty_profile.svg'; export { default as imgEmptyJoinProfile } from './img_empty_join.svg'; export { ReactComponent as ImgTeamAdd } from './img_team_add.svg'; +export { ReactComponent as ImgAnswerDone } from './img_answerdone.svg'; diff --git a/src/infrastructure/api/index.ts b/src/infrastructure/api/index.ts index e6979525..ebcad76d 100644 --- a/src/infrastructure/api/index.ts +++ b/src/infrastructure/api/index.ts @@ -1,7 +1,9 @@ import { neogaDataMock } from '../mock/neoga'; +import { neososeoFormDataMock } from '../mock/neososeo-form'; import { teamDataMock } from '../mock/team'; import { userDataMock } from '../mock/user'; import { NeogaService } from './neoga'; +import { NeososeoFormService } from './neososeo-form'; import { TeamService } from './team'; import { UserService } from './user'; @@ -15,12 +17,14 @@ function provideMockAPIService(): APIService { const teamService = teamDataMock(); const userService = userDataMock(); const neogaService = neogaDataMock(); + const neososeoFormService = neososeoFormDataMock(); - return { teamService, userService, neogaService }; + return { teamService, userService, neogaService, neososeoFormService }; } export interface APIService { teamService: TeamService; userService: UserService; neogaService: NeogaService; + neososeoFormService: NeososeoFormService; } diff --git a/src/infrastructure/api/neososeo-form.ts b/src/infrastructure/api/neososeo-form.ts new file mode 100644 index 00000000..fd08532a --- /dev/null +++ b/src/infrastructure/api/neososeo-form.ts @@ -0,0 +1,6 @@ +import { NeososeoAnswerData, NeososeoFormData } from './types/neososeo-form'; + +export interface NeososeoFormService { + getFormInfo(userID: string, formID: string): Promise; + postFormAnswer(body: NeososeoAnswerData): Promise<{ isSuccess: boolean }>; +} diff --git a/src/infrastructure/api/types/neososeo-form.ts b/src/infrastructure/api/types/neososeo-form.ts new file mode 100644 index 00000000..9337d177 --- /dev/null +++ b/src/infrastructure/api/types/neososeo-form.ts @@ -0,0 +1,23 @@ +export type Relation = { + id: number; + content: string; +}; + +export type NeososeoFormData = { + title: string; + content: string; + imageSub: string; + relation: Relation[]; + userName: string; + userID: number; + formID: number; +}; + +export type NeososeoAnswerData = { + userID: number; + formID: number; + name: string; + relationID: number; + answer: string; + keyword: string[]; +}; diff --git a/src/infrastructure/mock/neososeo-form.data.ts b/src/infrastructure/mock/neososeo-form.data.ts new file mode 100644 index 00000000..5de6cfbf --- /dev/null +++ b/src/infrastructure/mock/neososeo-form.data.ts @@ -0,0 +1,19 @@ +import { NeososeoFormData } from '@api/types/neososeo-form'; + +export const NEOSOSEO_FORM_DATA: { FORM: NeososeoFormData } = { + FORM: { + title: '너가 닮고 싶은\n나의 일잘러 모습', + content: '나와 함께하며 당신이 닮고 싶었던 능력이 있었나요?', + relation: [ + { id: 1, content: '동네친구' }, + { id: 2, content: '쿵짝최고' }, + { id: 3, content: '존경해요' }, + { id: 4, content: '찐친베프' }, + ], + imageSub: + 'https://ww.namu.la/s/61e3a8075b5aa238383c0d89badd3442f7389a0285575fc5bc5c16d2a34f22c66e57e35d568b63b706fa24750c784d55e972ceeea93fa5a91d00dab1eea37681e189ae826afe668fb379b0cb3a446f3268755691ed20c6a165185d44e2fd1029896062ed4df01a806594e35a637b2372', + userName: '강쥐', + userID: 1, + formID: 1, + }, +}; diff --git a/src/infrastructure/mock/neososeo-form.ts b/src/infrastructure/mock/neososeo-form.ts new file mode 100644 index 00000000..c95420f1 --- /dev/null +++ b/src/infrastructure/mock/neososeo-form.ts @@ -0,0 +1,20 @@ +import { NeososeoFormService } from '@api/neososeo-form'; +import { NeososeoAnswerData } from '@api/types/neososeo-form'; +import { NEOSOSEO_FORM_DATA } from './neososeo-form.data'; + +export function neososeoFormDataMock(): NeososeoFormService { + const getFormInfo = async () => { + await wait(2000); + return NEOSOSEO_FORM_DATA.FORM; + }; + + const postFormAnswer = async (body: NeososeoAnswerData) => { + console.log(body); + await wait(2000); + return { isSuccess: true }; + }; + + return { getFormInfo, postFormAnswer }; +} + +const wait = (milliSeconds: number) => new Promise((resolve) => setTimeout(resolve, milliSeconds)); diff --git a/src/presentation/components/common/NeososeoFormHeader/index.tsx b/src/presentation/components/common/NeososeoFormHeader/index.tsx new file mode 100644 index 00000000..1721e50a --- /dev/null +++ b/src/presentation/components/common/NeososeoFormHeader/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { StNeososeoFormHeader } from './style'; + +interface NeososeoFormHeaderProps { + title: string; + image: string; +} + +function NeososeoFormHeader(props: NeososeoFormHeaderProps) { + const { title, image } = props; + return ( + +
{title}
+
+ {title} +
+
+ ); +} + +export default NeososeoFormHeader; diff --git a/src/presentation/components/common/NeososeoFormHeader/style.ts b/src/presentation/components/common/NeososeoFormHeader/style.ts new file mode 100644 index 00000000..5c5eb4c5 --- /dev/null +++ b/src/presentation/components/common/NeososeoFormHeader/style.ts @@ -0,0 +1,23 @@ +import { COLOR } from '@styles/common/color'; +import { FONT_STYLES } from '@styles/common/font-style'; +import styled from 'styled-components'; + +export const StNeososeoFormHeader = styled.div` + width: 100vw; + display: grid; + grid-template-columns: auto 68px; + white-space: pre; + padding: 0 20px; + + & div { + line-height: 33px; + ${FONT_STYLES.SB_22_BODY} + } + + & img { + width: 68px; + height: 68px; + border-radius: 34px; + background-color: ${COLOR.GRAY_1}; + } +`; diff --git a/src/presentation/pages/NeososeoForm/Answer/index.tsx b/src/presentation/pages/NeososeoForm/Answer/index.tsx new file mode 100644 index 00000000..129609f9 --- /dev/null +++ b/src/presentation/pages/NeososeoForm/Answer/index.tsx @@ -0,0 +1,81 @@ +import { api } from '@api/index'; +import { Keyword } from '@api/types/user'; +import CommonInput from '@components/common/CommonInput'; +import ImmutableKeywordList from '@components/common/Keyword/ImmutableList'; +import { neososeoAnswerState, neososeoFormState } from '@stores/neososeo-form'; +import { isAllFilled } from '@utils/string'; +import { useEffect, useState } from 'react'; +import { Link, Outlet, useNavigate } from 'react-router-dom'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { StButton, StNeososeoFormLayout, StNeososeoTitle, StSubTitle } from '../style'; +import { StTextarea, StKeywordListWrapper } from './style'; + +function NeososeoFormAnswer() { + const neososeoFormData = useRecoilValue(neososeoFormState); + const [keywordList, setKeywordList] = useState([]); + const [neososeoAnswer, setNeososeoAnswer] = useRecoilState(neososeoAnswerState); + const navigate = useNavigate(); + + const postNeososeoForm = async () => { + const response = await api.neososeoFormService.postFormAnswer(neososeoAnswer); + if (response.isSuccess) navigate('../finish'); + }; + + const setAnswer = (answer: string) => setNeososeoAnswer((prev) => ({ ...prev, answer })); + + useEffect(() => { + setNeososeoAnswer((prev) => ({ ...prev, keyword: keywordList.map((k) => k.id) })); + }, [keywordList]); + + if (!neososeoFormData) return <>; + + return ( + <> + +
+ + Q. + {neososeoFormData.content} + + 답변 내용을 입력해주세요. + setAnswer(e.target.value)} /> + 키워드를 입력해주세요. + + + + + null} /> + +
+ + 답변 작성하기 + +
+ + setKeywordList((prev) => + prev.map((prev) => prev.content).includes(keyword.content) + ? prev + : [...prev, keyword], + ), + removeKeyword: (targetKeyword: Keyword) => + setKeywordList((prev) => + prev.filter((keyword) => keyword.content !== targetKeyword.content), + ), + targetUser: neososeoFormData.userID, + }} + /> + + ); +} + +export default NeososeoFormAnswer; diff --git a/src/presentation/pages/NeososeoForm/Answer/style.ts b/src/presentation/pages/NeososeoForm/Answer/style.ts new file mode 100644 index 00000000..3b5ae2aa --- /dev/null +++ b/src/presentation/pages/NeososeoForm/Answer/style.ts @@ -0,0 +1,13 @@ +import { COMMON_INPUT } from '@styles/common/input'; +import styled from 'styled-components'; + +export const StTextarea = styled.textarea` + ${COMMON_INPUT} + width: 100%; + resize: unset; + height: 104px; +`; + +export const StKeywordListWrapper = styled.div` + margin-top: 18px; +`; diff --git a/src/presentation/pages/NeososeoForm/Finish/index.tsx b/src/presentation/pages/NeososeoForm/Finish/index.tsx new file mode 100644 index 00000000..82840a30 --- /dev/null +++ b/src/presentation/pages/NeososeoForm/Finish/index.tsx @@ -0,0 +1,21 @@ +import { ImgAnswerDone } from '@assets/images'; +import { useNavigate } from 'react-router-dom'; +import { StButton } from '../style'; +import { StBody, StNeososeoFinish } from './style'; + +function NeososeoFormFinish() { + const navigate = useNavigate(); + + return ( + + + +
답변이 전달되었어요
+
너가소개서, 좀 더 둘러보실래요?
+
+ navigate('/')}>서비스 둘러보기 +
+ ); +} + +export default NeososeoFormFinish; diff --git a/src/presentation/pages/NeososeoForm/Finish/style.ts b/src/presentation/pages/NeososeoForm/Finish/style.ts new file mode 100644 index 00000000..c0955998 --- /dev/null +++ b/src/presentation/pages/NeososeoForm/Finish/style.ts @@ -0,0 +1,33 @@ +import { COLOR } from '@styles/common/color'; +import { FONT_STYLES } from '@styles/common/font-style'; +import styled from 'styled-components'; + +export const StBody = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; + +export const StNeososeoFinish = styled.div` + height: 100vh; + width: 100vw; + padding: 50px 20px; + display: grid; + grid-template-rows: auto 58px; + & > div:nth-child(1) { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-bottom: 52px; + & > div:nth-child(2) { + color: ${COLOR.GRAY_8}; + ${FONT_STYLES.SB_20_TITLE} + } + & > div:nth-child(3) { + margin-top: 8px; + color: ${COLOR.GRAY_5}; + ${FONT_STYLES.R_15_TITLE} + } + } +`; diff --git a/src/presentation/pages/NeososeoForm/Home/index.tsx b/src/presentation/pages/NeososeoForm/Home/index.tsx new file mode 100644 index 00000000..453d96b1 --- /dev/null +++ b/src/presentation/pages/NeososeoForm/Home/index.tsx @@ -0,0 +1,22 @@ +import { neososeoFormState } from '@stores/neososeo-form'; +import { useNavigate } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; +import { StButton } from '../style'; +import { StNeososeoFormHome } from './style'; + +function NeososeoFormHome() { + const neososeoFormData = useRecoilValue(neososeoFormState); + const navigate = useNavigate(); + + if (!neososeoFormData) return <>; + return ( + +
+
{neososeoFormData.content}
+
+ navigate('intro')}>답변 작성하기 +
+ ); +} + +export default NeososeoFormHome; diff --git a/src/presentation/pages/NeososeoForm/Home/style.ts b/src/presentation/pages/NeososeoForm/Home/style.ts new file mode 100644 index 00000000..070f70c9 --- /dev/null +++ b/src/presentation/pages/NeososeoForm/Home/style.ts @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +export const StNeososeoFormHome = styled.div` + width: calc(100vw); + padding: 0 20px; + height: 100%; + display: grid; + grid-template-rows: auto 58px; + + & > div:nth-child(1) { + display: flex; + align-items: center; + padding-bottom: 40px; + } + + & img { + width: calc(100vw - 40px); + } +`; diff --git a/src/presentation/pages/NeososeoForm/Intro/index.tsx b/src/presentation/pages/NeososeoForm/Intro/index.tsx new file mode 100644 index 00000000..def82f8d --- /dev/null +++ b/src/presentation/pages/NeososeoForm/Intro/index.tsx @@ -0,0 +1,73 @@ +import { Relation } from '@api/types/neososeo-form'; +import CommonInput from '@components/common/CommonInput'; +import { neososeoAnswerState, neososeoFormState } from '@stores/neososeo-form'; +import { isAllFilled } from '@utils/string'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { + StButton, + StNeososeoFormLayout, + StNeososeoTitle, + StRelation, + StRelationWrapper, + StSubTitle, +} from '../style'; + +function NeososeoFormIntro() { + const neososeoFormData = useRecoilValue(neososeoFormState); + const [relation, setRelation] = useState(null); + const [neososeoAnswer, setNeososeoAnswer] = useRecoilState(neososeoAnswerState); + const navigate = useNavigate(); + + const setUserName = (userName: string) => + setNeososeoAnswer((prev) => ({ ...prev, name: userName })); + + const setAnswerRelation = (relationID: number) => + setNeososeoAnswer((prev) => ({ ...prev, relationID })); + + useEffect(() => { + if (!relation) return; + setAnswerRelation(relation.id); + }, [relation]); + + useEffect(() => { + if (!neososeoFormData) return; + setRelation(neososeoFormData.relation[0]); + }, [neososeoFormData]); + + if (!neososeoFormData) return <>; + return ( + + + Q. + {neososeoFormData.content} + +
+ 나를 소개해주세요 + setUserName(name)} + /> + {neososeoFormData.userName}님과의 관계를 선택해주세요 + + {neososeoFormData.relation.map((relation) => ( + setRelation(relation)} + > + {relation.content} + + ))} + +
+ navigate('../answer')} disabled={!isAllFilled(neososeoAnswer.name)}> + 다음 + +
+ ); +} + +export default NeososeoFormIntro; diff --git a/src/presentation/pages/NeososeoForm/index.tsx b/src/presentation/pages/NeososeoForm/index.tsx new file mode 100644 index 00000000..a92548fd --- /dev/null +++ b/src/presentation/pages/NeososeoForm/index.tsx @@ -0,0 +1,34 @@ +import { api } from '@api/index'; +import NeososeoFormHeader from '@components/common/NeososeoFormHeader'; +import FormRouter from '@routes/FormRouter'; +import { neososeoAnswerState, neososeoFormState } from '@stores/neososeo-form'; +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { StNeososeoFormPage } from './style'; + +function NeososeoFormPage() { + const { userID, formID } = useParams(); + const [neososeoForm, setNeososeoForm] = useRecoilState(neososeoFormState); + const setNeoseosoAnswer = useSetRecoilState(neososeoAnswerState); + + useEffect(() => { + if (!userID || !formID) return; + (async () => { + const data = await api.neososeoFormService.getFormInfo(userID, formID); + setNeososeoForm(data); + setNeoseosoAnswer((prev) => ({ ...prev, userID: data.userID, formID: data.formID })); + })(); + }, [userID, formID]); + + return ( + + {neososeoForm && ( + + )} + + + ); +} + +export default NeososeoFormPage; diff --git a/src/presentation/pages/NeososeoForm/style.ts b/src/presentation/pages/NeososeoForm/style.ts new file mode 100644 index 00000000..a5671977 --- /dev/null +++ b/src/presentation/pages/NeososeoForm/style.ts @@ -0,0 +1,73 @@ +import { CORAL_MAIN_BUTTON, FULL_WIDTH_BUTTON } from '@styles/common/button'; +import { COLOR } from '@styles/common/color'; +import { FONT_STYLES } from '@styles/common/font-style'; +import styled from 'styled-components'; + +export const StNeososeoFormPage = styled.div` + min-height: 100vh; + width: 100vw; + padding: 50px 0; + display: grid; + grid-template-rows: 70px auto; + position: relative; +`; + +export const StNeososeoTitle = styled.div` + border-radius: 18px; + width: 100%; + display: flex; + gap: 8px; + padding: 16px 20px; + background-color: ${COLOR.GRAY_1}; + margin-top: 24px; + + & > span:nth-child(1) { + color: ${COLOR.CORAL_MAIN}; + line-height: 20px; + } + + & > span:nth-child(2) { + ${FONT_STYLES.R_15_BODY} + color: ${COLOR.GRAY_7}; + line-height: 20px; + } +`; + +export const StSubTitle = styled.div` + margin-top: 44px; + margin-bottom: 18px; + ${FONT_STYLES.SB_16_TITLE} + color: ${COLOR.GRAY_8}; +`; + +export const StRelationWrapper = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +`; + +export const StRelation = styled.div<{ selected: boolean }>` + ${FONT_STYLES.R_15_TITLE} + padding: 15px 0; + border-radius: 14px; + text-align: center; + color: ${({ selected }) => (selected ? COLOR.CORAL_MAIN : COLOR.GRAY_5)}; + background-color: ${({ selected }) => (selected ? '#FFEFEF' : COLOR.GRAY_1)}; +`; + +export const StButton = styled.button` + ${FULL_WIDTH_BUTTON} + background-color: ${COLOR.GRAY_3}; + color: ${COLOR.WHITE}; + margin-top: auto; + + :not(:disabled) { + ${CORAL_MAIN_BUTTON} + } +`; + +export const StNeososeoFormLayout = styled.div` + display: flex; + flex-direction: column; + padding: 0 20px; +`; diff --git a/src/presentation/pages/Team/Issue/Keyword/style.ts b/src/presentation/pages/Team/Issue/Keyword/style.ts index c5c8a208..0f805448 100644 --- a/src/presentation/pages/Team/Issue/Keyword/style.ts +++ b/src/presentation/pages/Team/Issue/Keyword/style.ts @@ -7,6 +7,8 @@ export const StAbsoluteWrapper = styled.div` position: absolute; width: 100vw; height: 100vh; + top: 0; + left: 0; background-color: ${COLOR.WHITE}; z-index: 300; animation: ${ANIMATION.SWIPE_FROM_RIGHT} 1s; diff --git a/src/presentation/routes/FormRouter.tsx b/src/presentation/routes/FormRouter.tsx new file mode 100644 index 00000000..d796d1ed --- /dev/null +++ b/src/presentation/routes/FormRouter.tsx @@ -0,0 +1,19 @@ +import NeososeoFormAnswer from '@pages/NeososeoForm/Answer'; +import NeososeoFormHome from '@pages/NeososeoForm/Home'; +import NeososeoFormIntro from '@pages/NeososeoForm/Intro'; +import TeamIssueKeyword from '@pages/Team/Issue/Keyword'; +import { Route, Routes } from 'react-router-dom'; + +function FormRouter() { + return ( + + } /> + } /> + }> + } /> + + + ); +} + +export default FormRouter; diff --git a/src/presentation/routes/HomeRouter.tsx b/src/presentation/routes/HomeRouter.tsx index f08be739..b4465660 100644 --- a/src/presentation/routes/HomeRouter.tsx +++ b/src/presentation/routes/HomeRouter.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Route, Routes } from 'react-router-dom'; import HomeMyPage from '@pages/Home/MyPage'; import HomeTeam from '@pages/Home/Team'; diff --git a/src/presentation/routes/Router.tsx b/src/presentation/routes/Router.tsx index 763a3c9d..93bc1a0c 100644 --- a/src/presentation/routes/Router.tsx +++ b/src/presentation/routes/Router.tsx @@ -1,3 +1,5 @@ +import NeososeoFormPage from '@pages/NeososeoForm'; +import NeososeoFormFinish from '@pages/NeososeoForm/Finish'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; import NeogaRouter from './NeogaRouter'; import TeamRouter from './TeamRouter'; @@ -10,6 +12,8 @@ const Router = () => { } /> } /> } /> + } /> + } /> );