diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 466863e4..74373f4c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -18,7 +18,7 @@ jobs: - name: nodeJS uses: actions/setup-node@v3 with: - node-version: 18.x + node-version: 18.16 cache: yarn - name: dependency install diff --git a/src/api/index.ts b/src/api/index.ts index fe7673e8..320f3f93 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,12 +1,15 @@ import { userKeys, userAPI } from './user'; import { boardKeys, boardAPI } from './board'; +import { writeAPI, writeKeys } from './write'; export const queryKeys = { user: userKeys, board: boardKeys, + write: writeKeys, } as const; export const api = { user: userAPI, board: boardAPI, + write: writeAPI, } as const; diff --git a/src/api/write/api.ts b/src/api/write/api.ts index 458802d2..2a684111 100644 --- a/src/api/write/api.ts +++ b/src/api/write/api.ts @@ -1,5 +1,9 @@ import { request } from '@/utils/ky/request'; -import { ParsedData, PostResponse } from '@/app/write/types'; +import { BoardData, ParsedData, PostResponse } from '@/app/write/types'; +interface Props { + boardId: number; + data: ParsedData; +} class WriteAPI { /** * 유저가 입력한 데이터로 먹팟을 생성합니다. @@ -19,7 +23,8 @@ class WriteAPI { * @param boardId - 수정할 board의 id * @param data - 유저가 입력한 board 데이터 */ - async patchBoard(boardId: number, { ...data }: ParsedData): Promise { + + async patchBoard({ boardId, data }: Props): Promise { return request .patch(`v2/boards/${boardId}`, { json: { @@ -28,6 +33,14 @@ class WriteAPI { }) .json(); } + + /** + * boardId에 해당하는 먹팟을 수정하기 위해 정보를 가져옵니다. + * @param boardId - 가져올 board의 id + */ + async getBoardDetail(boardId: number) { + return request.get(`v1/boards/${boardId}/update`).json(); + } } // api fetchers instance diff --git a/src/api/write/hooks/index.ts b/src/api/write/hooks/index.ts new file mode 100644 index 00000000..be25c216 --- /dev/null +++ b/src/api/write/hooks/index.ts @@ -0,0 +1,5 @@ +export { default as useGetBoard } from './useGetBoard'; + +export { default as usePostBoard } from './usePostBoard'; + +export { default as usePatchBoard } from './usePatchBoard'; diff --git a/src/api/write/hooks/useGetBoard.ts b/src/api/write/hooks/useGetBoard.ts new file mode 100644 index 00000000..e342dbad --- /dev/null +++ b/src/api/write/hooks/useGetBoard.ts @@ -0,0 +1,21 @@ +import { writeKeys } from '@/api/write/queryKeys'; +import { writeAPI } from '@/api/write/api'; +import { useSuspenseQuery } from '@suspensive/react-query'; +import { BoardData } from '@/app/write/types'; + +interface Props { + boardId: number; + onSuccess: (data: BoardData) => void; +} + +const useGetBoard = ({ boardId, onSuccess }: Props) => { + return useSuspenseQuery({ + queryKey: writeKeys.board(boardId), + queryFn: () => writeAPI.getBoardDetail(boardId), + onSuccess: (data) => { + onSuccess(data); + }, + }); +}; + +export default useGetBoard; diff --git a/src/api/write/hooks/usePatchBoard.ts b/src/api/write/hooks/usePatchBoard.ts new file mode 100644 index 00000000..7f8fcc0f --- /dev/null +++ b/src/api/write/hooks/usePatchBoard.ts @@ -0,0 +1,16 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useMutation } from '@/hooks'; +import { boardKeys } from '@/api/board'; +import { writeAPI } from '@/api/write/api'; + +const usePatchBoard = (boardId: number) => { + const queryClient = useQueryClient(); + + return useMutation(writeAPI.patchBoard, { + onSuccess: () => { + void queryClient.invalidateQueries(boardKeys.detail(boardId)); + }, + }); +}; + +export default usePatchBoard; diff --git a/src/api/write/index.ts b/src/api/write/index.ts new file mode 100644 index 00000000..48bdbdea --- /dev/null +++ b/src/api/write/index.ts @@ -0,0 +1,3 @@ +export * from './hooks'; +export * from './queryKeys'; +export * from './api'; diff --git a/src/api/write/queryKeys.ts b/src/api/write/queryKeys.ts new file mode 100644 index 00000000..383b097d --- /dev/null +++ b/src/api/write/queryKeys.ts @@ -0,0 +1,4 @@ +export const writeKeys = { + all: ['write'], + board: (boardId: number) => [...writeKeys.all, 'detail', boardId] as const, +}; diff --git a/src/app/board/components/DetailMenuButton/DetailMenuButton.tsx b/src/app/board/components/DetailMenuButton/DetailMenuButton.tsx index be3d5085..d5717b0c 100644 --- a/src/app/board/components/DetailMenuButton/DetailMenuButton.tsx +++ b/src/app/board/components/DetailMenuButton/DetailMenuButton.tsx @@ -48,6 +48,10 @@ const DetailMenuButton = ({ board }: Props) => { ); }; + const handleClickEditBoard = () => { + router.push(`/write/${boardId}`); + }; + const handleClickCancelJoinButton = () => { openModal( { {isWriter && ( <> - + 수정하기 diff --git a/src/app/write/[id]/page.tsx b/src/app/write/[id]/page.tsx new file mode 100644 index 00000000..57da83ba --- /dev/null +++ b/src/app/write/[id]/page.tsx @@ -0,0 +1,23 @@ +import { notFound } from 'next/navigation'; +import { InputLoading } from '../components'; +import { Suspense } from 'react'; +import BoardForm from '../components/Form/BoardForm'; + +const BoardEditPage = async ({ + params: { id }, +}: { + params: { + id: string; + }; +}) => { + const boardId = Number(id); + if (Number.isNaN(boardId)) notFound(); + + return ( + }> + + + ); +}; + +export default BoardEditPage; diff --git a/src/app/write/components/AgeModal/AgeBottomSheet.tsx b/src/app/write/components/AgeModal/AgeBottomSheet.tsx index c39c8731..d7d02c93 100644 --- a/src/app/write/components/AgeModal/AgeBottomSheet.tsx +++ b/src/app/write/components/AgeModal/AgeBottomSheet.tsx @@ -33,15 +33,19 @@ const AgeBottomSheet = ({ ...props }: TControl) => {
-
- - 최소 나이 선택 - - - - 최대 나이 선택 - - +
+
+ + 최소 나이 선택 + + +
+
+ + 최대 나이 선택 + + +
저장하기 diff --git a/src/app/write/components/AgeModal/AgeController.tsx b/src/app/write/components/AgeModal/AgeController.tsx index 5aaa78df..5e66aa12 100644 --- a/src/app/write/components/AgeModal/AgeController.tsx +++ b/src/app/write/components/AgeModal/AgeController.tsx @@ -1,6 +1,8 @@ import { Dropdown, DropdownButton, DropdownItem, DropdownMenu } from '@/components'; import { Control, Controller, FieldValues } from 'react-hook-form'; import { AGE_LIST } from '@/app/write/constants'; +import DropdownModal from '@/components/Dropdown/DropdownModal'; +import { useIsMobile } from '@/hooks'; type ControllerProps = { control: Control; @@ -11,23 +13,26 @@ type ControllerProps = { // eslint-disable-next-line @typescript-eslint/no-explicit-any const AgeController = ({ disabled = false, control, name, placeholder }: ControllerProps) => { + const isMobile = useIsMobile(); + const DropdownItemWrapper = isMobile ? DropdownModal : DropdownMenu; + return ( ( - + {value && `${value}세`} - + {AGE_LIST.map((v) => ( {v} ))} - + )} /> diff --git a/src/app/write/components/AgeModal/AgeModal.css.ts b/src/app/write/components/AgeModal/AgeModal.css.ts index 54aa3491..9401ea6d 100644 --- a/src/app/write/components/AgeModal/AgeModal.css.ts +++ b/src/app/write/components/AgeModal/AgeModal.css.ts @@ -30,18 +30,19 @@ export const modalContent = style({ flexDirection: 'row', justifyContent: 'space-evenly', alignItems: 'center', - gap: themeTokens.space.sm, + gap: space.sm, '@media': { [screenMQ.m]: { flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', - margin: space.xl, + margin: `${space['4xl']} ${space.xl}`, }, }, }); export const birthText = style({ ...fontVariant.body2, - color: themeTokens.color.sub, + color: color.sub, + margin: `${space.xl}`, }); diff --git a/src/app/write/components/AgeModal/AgeModal.tsx b/src/app/write/components/AgeModal/AgeModal.tsx index bfd0a1bc..9a99ea4c 100644 --- a/src/app/write/components/AgeModal/AgeModal.tsx +++ b/src/app/write/components/AgeModal/AgeModal.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Control, FieldValues, useFormContext } from 'react-hook-form'; import { HTMLAttributes, useCallback } from 'react'; import { Button, Modal, ModalContent, ModalFooter, ModalHeader, SvgIcon } from '@/components'; diff --git a/src/app/write/components/AgeModal/BirthYear.tsx b/src/app/write/components/AgeModal/BirthYear.tsx index df57bf8e..7b58415e 100644 --- a/src/app/write/components/AgeModal/BirthYear.tsx +++ b/src/app/write/components/AgeModal/BirthYear.tsx @@ -1,7 +1,6 @@ import dayjs from 'dayjs'; import { Control, FieldValues, useWatch } from 'react-hook-form'; -import { birthText, modalContent } from './AgeModal.css'; -import clsx from 'clsx'; +import { birthText } from './AgeModal.css'; type ModalProps = { control: Control; @@ -10,13 +9,13 @@ type ModalProps = { const BirthYear = ({ control }: ModalProps) => { const [min, max] = useWatch({ control, name: ['minAge', 'maxAge'] }); if (!min || !max) { - return
-
; + return
-
; } else { const year = dayjs().year(); const minBirthYear = (year - min).toString().slice(2, 4); const maxBirthYear = (year - max).toString().slice(2, 4); return ( -
+
{minBirthYear}년생 - {maxBirthYear}년생
); diff --git a/src/app/write/components/Counter/Counter.tsx b/src/app/write/components/Counter/Counter.tsx index 61be5d7e..e7bcf74c 100644 --- a/src/app/write/components/Counter/Counter.tsx +++ b/src/app/write/components/Counter/Counter.tsx @@ -1,3 +1,4 @@ +'use client'; import { useCallback, HTMLAttributes } from 'react'; import { FieldValues, FieldPath, Controller, Control, useFormContext } from 'react-hook-form'; import { IconButton } from '@/components'; diff --git a/src/app/write/components/Form/BoardForm.tsx b/src/app/write/components/Form/BoardForm.tsx new file mode 100644 index 00000000..095d48bf --- /dev/null +++ b/src/app/write/components/Form/BoardForm.tsx @@ -0,0 +1,46 @@ +'use client'; + +import FirstStep from './FirstStep'; +import SecondStep from './SecondStep'; +import { wrapper } from '../../style.css'; +import { useFunnel } from '@/hooks'; +import { useProfile } from '@/api/hooks'; +import { useParams, useRouter } from 'next/navigation'; +import WriteTitle from '../WriteTitle/WriteTitle'; +import useSetFormData from '../../hooks/useSetFormData'; +import useFormStore from '../../store/useFormStore'; +import { useEffect } from 'react'; + +export default function BoardForm() { + const { data } = useProfile(); + const router = useRouter(); + + const { id: boardId } = useParams() as { id: string }; + useSetFormData(boardId); + const { reset, setData } = useFormStore(); + const [step, { prevStep, nextStep }] = useFunnel(['1', '2']); + const preventClose = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ''; + }; + + useEffect(() => { + window.addEventListener('beforeunload', preventClose); + return () => { + reset(); + window.removeEventListener('beforeunload', preventClose); + }; + }, []); + + if (!data) { + router.push('/login'); + } + + return ( +
+ + {step === '1' && } + {step === '2' && } +
+ ); +} diff --git a/src/app/write/components/Form/FirstStep.tsx b/src/app/write/components/Form/FirstStep.tsx index 7481b78b..43d3cd22 100644 --- a/src/app/write/components/Form/FirstStep.tsx +++ b/src/app/write/components/Form/FirstStep.tsx @@ -1,23 +1,22 @@ 'use client'; + import { useCallback } from 'react'; import { FormProvider } from 'react-hook-form'; import { Button, Input, InputSection, Typography } from '@/components'; import { InputDate, Counter, AgeModal, AgeBottomSheet, MapModal, TimeDropDown } from '@/app/write/components'; -import useFormStore from '@/app/write/store/useFormStore'; import { StepOneData } from '@/app/write/types'; import { useWriteForm } from '@/app/write/hooks/useWriteForm'; -import { formWrapper, sectionGap, inputGap, submitButton, flexBetween } from './Form.css'; +import { formWrapper, sectionGap, inputGap, submitButton, flexBetween, ageError } from './Form.css'; import { useIsMobile } from '@/hooks'; type stepProps = { nextStep: () => void; + setData: ({ step, data }: { step: 1; data: StepOneData }) => void; }; -const FirstStep = ({ nextStep }: stepProps) => { +const FirstStep = ({ nextStep, setData }: stepProps) => { const { stepOneMethod } = useWriteForm(); - const { setData } = useFormStore(); const mobile = useIsMobile(); - const onSubmit = useCallback( (data: StepOneData) => { if (!data) { @@ -66,7 +65,7 @@ const FirstStep = ({ nextStep }: stepProps) => { )} - + {stepOneMethod.formState.errors['maxAge']?.message}
@@ -84,17 +83,12 @@ const FirstStep = ({ nextStep }: stepProps) => { {...stepOneMethod.register('locationDetail', { required: false })} name={'locationDetail'} placeholder="ex) 1층 로비, 식당 입구" - maxLength={100} + maxLength={30} >
- diff --git a/src/app/write/components/Form/Form.css.ts b/src/app/write/components/Form/Form.css.ts index a74ddfee..82fea485 100644 --- a/src/app/write/components/Form/Form.css.ts +++ b/src/app/write/components/Form/Form.css.ts @@ -36,3 +36,13 @@ export const flexBetween = style({ flexDirection: 'row', justifyContent: 'space-between', }); + +export const ageError = style({ + margin: '0', + textAlign: 'end', + '@media': { + [screenMQ.m]: { + textAlign: 'start', + }, + }, +}); diff --git a/src/app/write/components/Form/SecondStep.tsx b/src/app/write/components/Form/SecondStep.tsx index e4ff82ab..8ffff9dd 100644 --- a/src/app/write/components/Form/SecondStep.tsx +++ b/src/app/write/components/Form/SecondStep.tsx @@ -1,42 +1,68 @@ 'use client'; + import { FormProvider, SubmitHandler } from 'react-hook-form'; -import useFormStore from '@/app/write/store/useFormStore'; import { BoardData, PostResponse } from '@/app/write/types'; import { Button, Input, InputSection, TextArea, Toast, Typography } from '@/components'; import { formWrapper, inputGap } from './Form.css'; import parseData from './util/parseData'; -import useWriteBoard from '@/api/write/hooks/usePostBoard'; import { useRouter } from 'next/navigation'; -import { useWriteForm } from '../../hooks/useWriteForm'; +import { useWriteForm } from '@/app/write/hooks/useWriteForm'; import { useOverlay } from '@/hooks'; +import { usePostBoard, usePatchBoard } from '@/api/write'; + +type StepProps = { + reset: () => void; + boardId: number; + isPatch?: boolean; +}; -const SecondStep = () => { - const { mutate: board } = useWriteBoard(); +const SecondStep = ({ reset, boardId, isPatch = false }: StepProps) => { + const { mutate: board } = usePostBoard(); + const { mutate: patch } = usePatchBoard(boardId); const { stepTwoMethod } = useWriteForm(); const [openToast, closeToast] = useOverlay(); - const { reset } = useFormStore(); const router = useRouter(); const onSubmit: SubmitHandler = async (data: BoardData) => { if (!data) { return; } - board( - { ...parseData(data) }, - { - onSuccess: (response: PostResponse) => { - openToast(); - router.push(`/board/${response.boardId}`); - reset(); + if (!isPatch) { + board( + { ...parseData(data) }, + { + onSuccess: (response: PostResponse) => { + openToast(); + router.push(`/board/${response.boardId}`); + reset(); + }, + onError: (error) => { + openToast(); + if (error.response.status === 403) { + router.push('/login'); + } + }, }, - onError: (error) => { - openToast(); - if (error.response.status === 403) { - router.push('/login'); - } + ); + } + if (isPatch && boardId) { + patch( + { boardId: boardId, data: { ...parseData(data) } }, + { + onSuccess: () => { + openToast(); + router.push(`/board/${boardId}`); + reset(); + }, + onError: (error) => { + openToast(); + if (error.response.status === 403) { + router.push('/login'); + } + }, }, - }, - ); + ); + } }; return ( @@ -63,7 +89,7 @@ const SecondStep = () => { 베타서비스에서는 채팅 기능이 제공되지 않습니다.
효율적인 소통을 위해 오픈 채팅방을 만들어주세요. - diff --git a/src/app/write/components/InputDate/InputDate.tsx b/src/app/write/components/InputDate/InputDate.tsx index 8a3acfcd..388a4088 100644 --- a/src/app/write/components/InputDate/InputDate.tsx +++ b/src/app/write/components/InputDate/InputDate.tsx @@ -3,10 +3,7 @@ import dayjs from 'dayjs'; import { HTMLAttributes } from 'react'; import { FieldValues, FieldPath, Controller, Control } from 'react-hook-form'; -import { DateInput, DayPicker, BottomSheet } from '@/components'; -import { inputWrapper } from '@/components/Input/Input.css'; -import { useOverlay, useIsMobile } from '@/hooks'; -import { preventClick } from './InputDate.css'; +import { DateInput } from '@/components'; import InputErrorMessage from '@/components/Input/InputErrorMessage'; type TControl = { @@ -18,42 +15,20 @@ type TControl = { showError?: boolean; } & HTMLAttributes; -interface FieldProps { - value: Date; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onChange: (event: any) => void; -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any const InputDate = ({ ...props }: TControl) => { - const mobile = useIsMobile(); - const [openBottomSheet, closeBottomSheet] = useOverlay(); - const { name, control } = props; const now = dayjs().format('YYYY년 MM월 DD일 (오늘)'); - const renderBottomSheet = ({ value, onChange }: FieldProps) => { - return ( - - - - ); - }; - return ( ( -
{ - mobile && openBottomSheet(renderBottomSheet({ value, onChange })); - }} - > - + <> + -
+ )} /> ); diff --git a/src/app/write/components/InputLoading/InputLoading.css.ts b/src/app/write/components/InputLoading/InputLoading.css.ts new file mode 100644 index 00000000..3d281b23 --- /dev/null +++ b/src/app/write/components/InputLoading/InputLoading.css.ts @@ -0,0 +1,41 @@ +import { screenMQ } from '@/styles/theme.css'; +import { style } from '@vanilla-extract/css'; + +export const loadingForm = style({ + position: 'relative', + top: '116px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', +}); + +export const wrapper = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'center', + width: '100%', +}); + +export const row = style({ + display: 'grid', + gridAutoFlow: 'column', + gridAutoColumns: '1fr 3fr', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + marginBottom: '16px', + '@media': { + [screenMQ.m]: { + gridAutoFlow: 'row', + alignItems: 'flex-start', + gap: '1rem', + }, + }, +}); + +export const section = style({ + display: 'flex', + flexDirection: 'column', + gap: '56px', +}); diff --git a/src/app/write/components/InputLoading/InputLoading.tsx b/src/app/write/components/InputLoading/InputLoading.tsx new file mode 100644 index 00000000..047cca8d --- /dev/null +++ b/src/app/write/components/InputLoading/InputLoading.tsx @@ -0,0 +1,36 @@ +import { Skeleton } from '@/components'; +import { loadingForm, row, section, wrapper } from './InputLoading.css'; + +const SkeletonInput = () => { + return ( +
+ + {Array(2) + .fill(0) + .map((_, index) => ( +
+ + +
+ ))} +
+ ); +}; + +const InputLoading = () => { + return ( +
+
+ + + + +
+
+ +
+
+ ); +}; + +export default InputLoading; diff --git a/src/app/write/components/MapModal/MapModal.tsx b/src/app/write/components/MapModal/MapModal.tsx index cb34bf62..11a95231 100644 --- a/src/app/write/components/MapModal/MapModal.tsx +++ b/src/app/write/components/MapModal/MapModal.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useFormContext } from 'react-hook-form'; import { Map, Input } from '@/components'; import { useOverlay } from '@/hooks'; @@ -12,12 +14,13 @@ const MapModal = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const onClick = (data: any) => { if (data) { - method.setValue('locationName', `${data.address_name} ${data.place_name}`); + method.setValue('locationName', `${data.place_name}` || `${data.address_name}`); + method.setValue('addressName ', `${data.address_name}`); if (data.x && data.y) { method.setValue('x', parseFloat(data.x)); method.setValue('y', parseFloat(data.y)); - method.setValue('region_1depth_name', parseFloat(data.region_1depth_name)); - method.setValue('region_2depth_name', parseFloat(data.region_2depth_name)); + method.setValue('region_1depth_name', data.region_1depth_name); + method.setValue('region_2depth_name', data.region_2depth_name); } } closeModal(); diff --git a/src/app/write/components/TimeDropDown/TimeDropDown.tsx b/src/app/write/components/TimeDropDown/TimeDropDown.tsx index a23153e2..97d2bd6c 100644 --- a/src/app/write/components/TimeDropDown/TimeDropDown.tsx +++ b/src/app/write/components/TimeDropDown/TimeDropDown.tsx @@ -1,3 +1,5 @@ +'use client'; + import { InputDropdown, InputSection } from '@/components'; import { TIMELIST_AM, TIMELIST_PM, TIME_SELECT } from '../../constants'; import { useFormContext } from 'react-hook-form'; diff --git a/src/app/write/components/index.ts b/src/app/write/components/index.ts index 39db6aff..09f0aa70 100644 --- a/src/app/write/components/index.ts +++ b/src/app/write/components/index.ts @@ -7,3 +7,4 @@ export { default as SecondStep } from './Form/SecondStep'; export { default as MapModal } from './MapModal/MapModal'; export { default as WriteTitle } from './WriteTitle/WriteTitle'; export { default as TimeDropDown } from './TimeDropDown/TimeDropDown'; +export { default as InputLoading } from './InputLoading/InputLoading'; diff --git a/src/app/write/hooks/useSetFormData.ts b/src/app/write/hooks/useSetFormData.ts new file mode 100644 index 00000000..0bc007f6 --- /dev/null +++ b/src/app/write/hooks/useSetFormData.ts @@ -0,0 +1,53 @@ +import { ParsedData, StepOneData, StepTwoData } from '../types'; +import useFormStore from '../store/useFormStore'; +import { useGetBoard } from '@/api/write'; + +const getParsedData = (board: ParsedData) => { + const date = new Date(board.meetingDate); + const timezone = board.meetingTime.startsWith('오후') ? '오후' : '오전'; + const time = board.meetingTime.slice(3); + const stepOneData: StepOneData = { + meetingDate: date, + meetingTime: time, + maxApply: board.maxApply, + minAge: board.minAge, + maxAge: board.maxAge, + timezone: timezone, + region_1depth_name: board.region_1depth_name, + region_2depth_name: board.region_2depth_name, + locationName: board.locationName, + addressName: board.addressName, + x: board.x, + y: board.y, + locationDetail: board.locationDetail, + }; + + const stepTwoData: StepTwoData = { + title: board.title, + content: board.content, + chatLink: board.chatLink, + }; + + return { + stepOneData, + stepTwoData, + }; +}; + +const useSetFormData = (boardId: string) => { + const { initData } = useFormStore(); + const id = Number(boardId); + + useGetBoard({ + boardId: id, + onSuccess: (boardData) => { + const { stepOneData, stepTwoData } = getParsedData(boardData); + initData({ + stepOneData, + stepTwoData, + }); + }, + }); +}; + +export default useSetFormData; diff --git a/src/app/write/hooks/useWriteForm.ts b/src/app/write/hooks/useWriteForm.ts index e462f816..165772f6 100644 --- a/src/app/write/hooks/useWriteForm.ts +++ b/src/app/write/hooks/useWriteForm.ts @@ -8,13 +8,16 @@ export const useWriteForm = () => { const stepOneMethod = useForm({ resolver: zodResolver(stepOneSchema), - mode: 'onChange', - defaultValues: stepOne || {}, + mode: 'onSubmit', + defaultValues: { ...stepOne }, + values: { ...stepOne }, }); const stepTwoMethod = useForm({ resolver: zodResolver(boardSchema), + mode: 'onSubmit', defaultValues: { ...stepOne, ...stepTwo }, + values: { ...stepOne, ...stepTwo }, }); return { stepOneMethod, stepTwoMethod }; diff --git a/src/app/write/lib/schema.ts b/src/app/write/lib/schema.ts index d9d9810e..7a47d127 100644 --- a/src/app/write/lib/schema.ts +++ b/src/app/write/lib/schema.ts @@ -8,20 +8,18 @@ export const stepOneSchema = z required_error: '필수 항목을 입력해주세요.', }) .default(new Date()), - timezone: z - .string({ invalid_type_error: '필수 항목을 입력해주세요.', required_error: '필수 항목을 입력해주세요.' }) - .default('오후'), - meetingTime: z - .string({ invalid_type_error: '필수 항목을 입력해주세요.', required_error: '필수 항목을 입력해주세요.' }) - .default('12:00'), + timezone: z.string().default('오후'), + meetingTime: z.string().default('12:00'), maxApply: z.coerce.number().min(2, { message: '최소 인원은 2명 이상 가능합니다.' }).default(2), minAge: z.coerce.number().min(20).max(100).optional().nullable().default(null), maxAge: z.coerce.number().min(20).max(100).optional().nullable().default(null), - locationName: z - .string({ invalid_type_error: '필수 항목을 입력해주세요.', required_error: '필수 항목을 입력해주세요.' }) - .min(5, { message: '필수 항목을 입력해주세요.' }), + locationName: z.string({ + invalid_type_error: '필수 항목을 입력해주세요.', + required_error: '필수 항목을 입력해주세요.', + }), x: z.number(), y: z.number(), + addressName: z.coerce.string(), region_1depth_name: z.coerce.string(), region_2depth_name: z.coerce.string(), locationDetail: z.string().optional().nullable().default(null), @@ -45,7 +43,7 @@ export const stepTwoSchema = z.object({ content: z.string().nullable().optional(), chatLink: z .string({ invalid_type_error: '필수 입력 항목을 입력해주세요.', required_error: '필수 입력 항목을 입력해주세요.' }) - .url({ message: '올바른 형태의 url을 입력해 주세요.' }) + .nonempty({ message: '필수 입력 항목을 입력해주세요.' }) .max(300, { message: '링크는 300(자)를 넘을 수 없습니다.' }) .regex(new RegExp('https://open.kakao.com/o/[A-Za-z0-9]+'), { message: '올바른 형태의 url을 입력해 주세요.' }), }); diff --git a/src/app/write/page.tsx b/src/app/write/page.tsx index 9e539aad..8fe8b344 100644 --- a/src/app/write/page.tsx +++ b/src/app/write/page.tsx @@ -11,7 +11,7 @@ import { useRouter } from 'next/navigation'; export default function Write() { const [step, { prevStep, nextStep }] = useFunnel(['1', '2']); - const { reset } = useFormStore(); + const { reset, setData } = useFormStore(); const { data } = useProfile(); const router = useRouter(); if (!data) { @@ -35,8 +35,8 @@ export default function Write() { return (
- {step === '1' && } - {step === '2' && } + {step === '1' && } + {step === '2' && }
); } diff --git a/src/app/write/store/useFormStore.tsx b/src/app/write/store/useFormStore.tsx index 92bed2fc..5e85c0ad 100644 --- a/src/app/write/store/useFormStore.tsx +++ b/src/app/write/store/useFormStore.tsx @@ -9,24 +9,53 @@ const stepVariant = { }; type setDataType = { step: 1; data: StepOneData } | { step: 2; data: StepTwoData }; +type initDataType = { stepOneData: StepOneData; stepTwoData: StepTwoData }; + +const INIT_STEPONE = { + meetingDate: new Date(), + timezone: '오후', + meetingTime: '12:00', + maxApply: 2, + minAge: null, + maxAge: null, + locationName: '', + addressName: '', + x: 0, + y: 0, + region_1depth_name: '', + region_2depth_name: '', + locationDetail: null, +}; + +const INIT_STEPTWO = { + title: '', + content: '', + chatLink: '', +}; interface FormState { - stepOne: StepOneData | null; - stepTwo: StepTwoData | null; + stepOne: StepOneData; + stepTwo: StepTwoData; + initData: ({ stepOneData, stepTwoData }: initDataType) => void; setData: ({ step, data }: setDataType) => void; reset: () => void; } const useFormStore = create()( devtools((set) => ({ - stepOne: null, - stepTwo: null, + stepOne: INIT_STEPONE, + stepTwo: INIT_STEPTWO, + initData: ({ stepOneData, stepTwoData }) => + set({ + stepOne: stepOneData, + stepTwo: stepTwoData, + }), setData: ({ step, data }) => set((state) => ({ ...state, [stepVariant[step]]: data, })), - reset: () => set((state) => ({ ...state, stepOne: null, stepTwo: null })), + reset: () => set((state) => ({ ...state, stepOne: INIT_STEPONE, stepTwo: INIT_STEPTWO })), })), ); diff --git a/src/app/write/style.css.ts b/src/app/write/style.css.ts index 1717fe8d..02907acf 100644 --- a/src/app/write/style.css.ts +++ b/src/app/write/style.css.ts @@ -17,7 +17,7 @@ export const wrapper = style({ maxWidth: sizeProp(400), marginTop: '7.25rem', marginBottom: space.xl, - padding: `${space.lg} ${space.xl} 0 ${space.xl}`, + padding: `0 ${space.xl}`, }, }, }); diff --git a/src/app/write/types/index.ts b/src/app/write/types/index.ts index d1789d6d..59a535bb 100644 --- a/src/app/write/types/index.ts +++ b/src/app/write/types/index.ts @@ -6,6 +6,7 @@ export type StepOneData = { minAge: number | null; maxAge: number | null; locationName: string; + addressName: string; x: number; y: number; region_1depth_name: string; diff --git a/src/components/BottomSheet/BottomSheet.css.ts b/src/components/BottomSheet/BottomSheet.css.ts index 6c8982c5..9954281a 100644 --- a/src/components/BottomSheet/BottomSheet.css.ts +++ b/src/components/BottomSheet/BottomSheet.css.ts @@ -73,3 +73,12 @@ export const wrap = recipe({ open: false, }, }); + +export const itemWrapper = style({ + overflow: 'scroll', + selectors: { + '&::-webkit-scrollbar': { + display: 'none', + }, + }, +}); diff --git a/src/components/BottomSheet/BottomSheetView.tsx b/src/components/BottomSheet/BottomSheetView.tsx index 5e39b7d8..5aa05867 100644 --- a/src/components/BottomSheet/BottomSheetView.tsx +++ b/src/components/BottomSheet/BottomSheetView.tsx @@ -2,7 +2,7 @@ import { HTMLAttributes, forwardRef } from 'react'; import cx from 'classnames'; import { IconButton } from '@/components'; import { useLockScroll } from '@/hooks'; -import { titleWrapper, background, wrap } from './BottomSheet.css'; +import { titleWrapper, background, wrap, itemWrapper } from './BottomSheet.css'; interface Props extends HTMLAttributes { /** BottomSheet 제목으로 표시할 텍스트 */ @@ -21,13 +21,13 @@ const BottomSheetView = forwardRef( return ( <> -
-
+
+
{title}
- {children} +
{children}
); diff --git a/src/components/DateInput/DateInput.tsx b/src/components/DateInput/DateInput.tsx index 05b47994..b27b8ebf 100644 --- a/src/components/DateInput/DateInput.tsx +++ b/src/components/DateInput/DateInput.tsx @@ -3,10 +3,11 @@ import { useRef } from 'react'; import { DayPickerSingleProps, SelectSingleEventHandler } from 'react-day-picker'; -import { Dropdown, DropdownButton, DropdownMenu, DayPicker } from '@/components'; +import { Dropdown, DayPicker } from '@/components'; import { DropdownMenuHandle } from '@/components/Dropdown/DropdownMenu'; import { selectedDateCaption } from './utils/caption'; import { menu } from './DateInput.css'; +import { useIsMobile } from '@/hooks'; interface Props extends Omit { /** 선택된 날짜 상태값 */ @@ -17,10 +18,12 @@ interface Props extends Omit { const menuRef = useRef(null); + const mobile = useIsMobile(); const handleSelectDate: SelectSingleEventHandler = (date) => { onSelect(date); @@ -29,12 +32,18 @@ const DateInput = ({ selected, onSelect, placeholder = '날짜 선택', isError, return ( - + {selected ? selectedDateCaption(selected) : null} - - - - + + {mobile ? ( + + + + ) : ( + + + + )} ); }; diff --git a/src/components/DayPicker/DateCaption.tsx b/src/components/DayPicker/DateCaption.tsx index f10a40b5..8bb26039 100644 --- a/src/components/DayPicker/DateCaption.tsx +++ b/src/components/DayPicker/DateCaption.tsx @@ -15,6 +15,7 @@ const DateFormatCaption = ({ displayMonth }: CaptionProps) => {
{ /> ; +type Props = ComponentProps; const Dropdown = ({ children, ...rest }: Props) => { - return ( - - {children} - - ); + return ( + + {children} + + ); }; export default Dropdown; diff --git a/src/components/Dropdown/DropdownWrapper.tsx b/src/components/Dropdown/DropdownWrapper.tsx index ea9cb1b5..94df4b87 100644 --- a/src/components/Dropdown/DropdownWrapper.tsx +++ b/src/components/Dropdown/DropdownWrapper.tsx @@ -2,22 +2,26 @@ import clsx from 'classnames'; import { useClickOutside } from '@/hooks'; import { wrapper } from './Dropdown.css'; import { useDropdownContext } from './contexts/DropdownContext'; +import { HTMLAttributes } from 'react'; -type Props = React.HTMLAttributes; +interface Props extends HTMLAttributes { + disableClickOutside?: boolean; +} -const DropdownWrapper = ({ children, className, ...rest }: Props) => { - const { closeDropdown } = useDropdownContext(); - const ref = useClickOutside({ - onClickOutside: () => { - closeDropdown(); - }, - }); +const DropdownWrapper = ({ children, className, disableClickOutside = false, ...rest }: Props) => { + const { closeDropdown } = useDropdownContext(); + const ref = useClickOutside({ + onClickOutside: () => { + closeDropdown(); + }, + disabled: disableClickOutside, + }); - return ( -
- {children} -
- ); + return ( +
+ {children} +
+ ); }; export default DropdownWrapper; diff --git a/src/components/Input/Input.css.ts b/src/components/Input/Input.css.ts index 843d9773..bf9b0428 100644 --- a/src/components/Input/Input.css.ts +++ b/src/components/Input/Input.css.ts @@ -121,6 +121,10 @@ export const fixedError = recipe({ }, }); +export const bottomSheetContent = style({ + padding: `0 ${space.sm}`, +}); + globalStyle(`${clearButton} > img`, { margin: '0 auto', backgroundPosition: 'center', diff --git a/src/components/Input/InputDropdown.tsx b/src/components/Input/InputDropdown.tsx index 56f9a853..c15e368f 100644 --- a/src/components/Input/InputDropdown.tsx +++ b/src/components/Input/InputDropdown.tsx @@ -2,7 +2,7 @@ import { FieldValues, FieldPath, Controller, Control } from 'react-hook-form'; import { Dropdown } from '@/components'; import InputErrorMessage from './InputErrorMessage'; -import { inputWrapper } from './Input.css'; +import { inputWrapper, bottomSheetContent } from './Input.css'; import { HTMLAttributes } from 'react'; import { useIsMobile } from '@/hooks'; @@ -40,7 +40,13 @@ const InputDropdown = ({ ...props }: TControl) => { {value} {mobile ? ( - + {selections.map((v) => ( {v} diff --git a/src/components/Input/hook/useForwardRef.ts b/src/components/Input/hook/useForwardRef.ts deleted file mode 100644 index eadeb410..00000000 --- a/src/components/Input/hook/useForwardRef.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useRef, useEffect, ForwardedRef } from 'react'; - -const useForwardRef = (ref: ForwardedRef, initialValue: any = null) => { - const targetRef = useRef(initialValue); - - useEffect(() => { - if (!ref) return; - - if (typeof ref === 'function') { - ref(targetRef.current); - } else { - ref.current = targetRef.current; - } - }, [ref]); - - return targetRef; -}; - -export default useForwardRef; diff --git a/src/hooks/useClickOutside/useClickOutside.ts b/src/hooks/useClickOutside/useClickOutside.ts index 5604fc7b..06134eda 100644 --- a/src/hooks/useClickOutside/useClickOutside.ts +++ b/src/hooks/useClickOutside/useClickOutside.ts @@ -12,21 +12,26 @@ interface Props { onClickOutside: () => void; /** 외부 클릭 이벤트 종류 */ event?: keyof ClickOutsideEvents; + /** 비활성화 여부 */ + disabled?: boolean; } /** * @param {Props} props * @param {() => void} props.onClickOutside - 외부 클릭 이벤트 발생 시 실행할 함수 * @param {keyof ClickOutsideEvents} props.event - 외부 클릭 이벤트 종류 + * @param {boolean} props.disabled - 비활성화 여부 * @returns {React.RefObject} 외부 클릭 이벤트를 감지할 element ref */ const useClickOutside = ({ onClickOutside, event = 'mousedown', + disabled = false, }: Props): React.RefObject => { const ref = useRef(null); useEffect(() => { + if (disabled) return; const handleClickOutside = (e: ClickOutsideEvents[typeof event]) => { // ref가 overlay-container내에 없는데, overlay-container 안에서 발생한 이벤트 인 경우 if (!ref.current?.closest(OVERLAY_CONTAINER) && (e.target as HTMLElement).closest(OVERLAY_CONTAINER)) { @@ -41,7 +46,7 @@ const useClickOutside = ({ return () => { window.removeEventListener(event, handleClickOutside); }; - }, [onClickOutside, event]); + }, [onClickOutside, event, disabled]); return ref; };