diff --git a/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts b/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts new file mode 100644 index 000000000..319ff735f --- /dev/null +++ b/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts @@ -0,0 +1,28 @@ +import { css } from '@emotion/react'; + +export const s_bottomFixedStyles = css` + width: calc(100% + 1.6rem * 2); + max-width: 43rem; + + /* 버튼 컴포넌트의 full variants를 사용하려고 했으나 6rem보다 height값이 작아 직접 높이를 정의했어요(@해리) + full 버튼에 이미 의존하고 있는 컴포넌트들이 많아서 높이를 full 스타일을 변경할 수는 없었습니다. + */ + height: 6rem; + box-shadow: 0 -4px 4px rgb(0 0 0 / 25%); +`; + +export const s_bottomFixedButtonContainer = (height = 0) => css` + position: fixed; + bottom: 0; + left: 0; + transform: translateY(-${height}px); + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 6rem; + + background-color: transparent; +`; diff --git a/frontend/src/components/_common/Buttons/BottomFixedButton/index.tsx b/frontend/src/components/_common/Buttons/BottomFixedButton/index.tsx new file mode 100644 index 000000000..9e3d2450e --- /dev/null +++ b/frontend/src/components/_common/Buttons/BottomFixedButton/index.tsx @@ -0,0 +1,33 @@ +import type { ButtonHTMLAttributes } from 'react'; +import React from 'react'; + +import { Button } from '../Button'; +import { s_bottomFixedButtonContainer, s_bottomFixedStyles } from './BottomFixedButton.styles'; + +interface BottomFixedButtonProps extends ButtonHTMLAttributes { + children: React.ReactNode; + height?: number; + isLoading?: boolean; +} + +export default function BottomFixedButton({ + children, + height = 0, + isLoading = false, + ...props +}: BottomFixedButtonProps) { + return ( +
+ +
+ ); +} diff --git a/frontend/src/constants/inputFields.ts b/frontend/src/constants/inputFields.ts index e8ed5ef7b..63c563616 100644 --- a/frontend/src/constants/inputFields.ts +++ b/frontend/src/constants/inputFields.ts @@ -4,6 +4,10 @@ export const INPUT_FIELD_PATTERN = { password: /^\d{4}$/, // 4자리 숫자 }; +export const INPUT_RULES = { + minimumLength: 1, +}; + export const FIELD_DESCRIPTIONS = { meetingName: '약속 이름은 1~10자 사이로 입력해 주세요.', nickname: '닉네임은 1~5자 사이로 입력해 주세요.', diff --git a/frontend/src/constants/meeting.ts b/frontend/src/constants/meeting.ts new file mode 100644 index 000000000..14f859be9 --- /dev/null +++ b/frontend/src/constants/meeting.ts @@ -0,0 +1,7 @@ +export const CREATE_MEETING_STEPS = { + meetingName: '약속이름', + meetingHostInfo: '약속주최자정보', + meetingDateTime: '약속날짜시간정보', +} as const; + +export const meetingStepValues = Object.values(CREATE_MEETING_STEPS); diff --git a/frontend/src/hooks/useButtonOnKeyboard/useButtonOnKeyboard.ts b/frontend/src/hooks/useButtonOnKeyboard/useButtonOnKeyboard.ts new file mode 100644 index 000000000..6a33a9568 --- /dev/null +++ b/frontend/src/hooks/useButtonOnKeyboard/useButtonOnKeyboard.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +const INITIAL_BUTTON_HEIGHT = 0; + +const useButtonOnKeyboard = () => { + const [resizedButtonHeight, setResizedButtonHeight] = useState(INITIAL_BUTTON_HEIGHT); + + useEffect(() => { + const handleButtonHeightResize = () => { + if (!visualViewport?.height) return; + + setResizedButtonHeight(window.innerHeight - visualViewport.height); + }; + + // 약속 이름 -> 약속 주최자 정보 입력으로 넘어갈 때 다음 버튼을 모바일 키보드로 올리기 위해서 resize 이벤트가 발생하지 않더라도 초기에 실행되도록 구현했어요.(@해리) + handleButtonHeightResize(); + visualViewport?.addEventListener('resize', handleButtonHeightResize); + + return () => { + visualViewport?.removeEventListener('resize', handleButtonHeightResize); + }; + }, []); + + return resizedButtonHeight; +}; + +export default useButtonOnKeyboard; diff --git a/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts b/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts new file mode 100644 index 000000000..128dc2dc0 --- /dev/null +++ b/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts @@ -0,0 +1,87 @@ +import { useState } from 'react'; + +import useInput from '@hooks/useInput/useInput'; +import { INITIAL_END_TIME, INITIAL_START_TIME } from '@hooks/useTimeRangeDropdown/constants'; +import useTimeRangeDropdown from '@hooks/useTimeRangeDropdown/useTimeRangeDropdown'; + +import { usePostMeetingMutation } from '@stores/servers/meeting/mutation'; + +import { FIELD_DESCRIPTIONS, INPUT_FIELD_PATTERN, INPUT_RULES } from '@constants/inputFields'; + +const checkInputInvalid = (value: string, errorMessage: string | null) => + value.length < INPUT_RULES.minimumLength || errorMessage !== null; + +const useCreateMeeting = () => { + const meetingNameInput = useInput({ + pattern: INPUT_FIELD_PATTERN.meetingName, + errorMessage: FIELD_DESCRIPTIONS.meetingName, + }); + const isMeetingNameInvalid = checkInputInvalid( + meetingNameInput.value, + meetingNameInput.errorMessage, + ); + + const hostNickNameInput = useInput({ + pattern: INPUT_FIELD_PATTERN.nickname, + errorMessage: FIELD_DESCRIPTIONS.nickname, + }); + const hostPasswordInput = useInput({ + pattern: INPUT_FIELD_PATTERN.password, + errorMessage: FIELD_DESCRIPTIONS.password, + }); + const isHostInfoInvalid = + checkInputInvalid(hostNickNameInput.value, hostNickNameInput.errorMessage) || + checkInputInvalid(hostPasswordInput.value, hostPasswordInput.errorMessage); + + const [selectedDates, setSelectedDates] = useState>(new Set()); + + const hasDate = (date: string) => selectedDates.has(date); + const handleDateClick = (date: string) => { + setSelectedDates((prevDates) => { + const newSelectedDates = new Set(prevDates); + newSelectedDates.has(date) ? newSelectedDates.delete(date) : newSelectedDates.add(date); + + return newSelectedDates; + }); + }; + const areDatesUnselected = selectedDates.size < 1; + + const meetingTimeInput = useTimeRangeDropdown(); + + const isCreateMeetingFormInvalid = + isMeetingNameInvalid || (isHostInfoInvalid && areDatesUnselected); + + const { mutation: postMeetingMutation } = usePostMeetingMutation(); + + const handleMeetingCreateButtonClick = () => { + const selectedDatesArray = Array.from(selectedDates); + + postMeetingMutation.mutate({ + meetingName: meetingNameInput.value, + hostName: hostNickNameInput.value, + hostPassword: hostPasswordInput.value, + availableMeetingDates: selectedDatesArray, + meetingStartTime: meetingTimeInput.startTime.value, + // 시간상 24시는 존재하지 않기 때문에 백엔드에서 오류가 발생. 따라서 오전 12:00으로 표현하지만, 서버에 00:00으로 전송(@낙타) + meetingEndTime: + meetingTimeInput.endTime.value === INITIAL_END_TIME + ? INITIAL_START_TIME + : meetingTimeInput.endTime.value, + }); + }; + + return { + meetingNameInput, + isMeetingNameInvalid, + hostNickNameInput, + hostPasswordInput, + isHostInfoInvalid, + hasDate, + handleDateClick, + meetingTimeInput, + isCreateMeetingFormInvalid, + handleMeetingCreateButtonClick, + }; +}; + +export default useCreateMeeting; diff --git a/frontend/src/hooks/useFunnel/FunnelMain.tsx b/frontend/src/hooks/useFunnel/FunnelMain.tsx new file mode 100644 index 000000000..15214a7d4 --- /dev/null +++ b/frontend/src/hooks/useFunnel/FunnelMain.tsx @@ -0,0 +1,26 @@ +import type { ReactElement } from 'react'; +import React, { Children, isValidElement } from 'react'; + +import type { FunnelProps, StepProps, StepType } from './useFunnel.type'; + +const isValidFunnelChild = ( + child: React.ReactNode, +): child is ReactElement> => { + return isValidElement(child) && typeof child.props.name === 'string'; +}; + +export default function FunnelMain({ + steps, + step, + children, +}: FunnelProps) { + const childrenArray = Children.toArray(children) + .filter(isValidFunnelChild) + .filter((child) => steps.includes(child.props.name)); + + const targetStep = childrenArray.find((child) => child.props.name === step); + + if (!targetStep) return null; + + return <>{targetStep}; +} diff --git a/frontend/src/hooks/useFunnel/useFunnel.tsx b/frontend/src/hooks/useFunnel/useFunnel.tsx new file mode 100644 index 000000000..9f5502d57 --- /dev/null +++ b/frontend/src/hooks/useFunnel/useFunnel.tsx @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import FunnelMain from './FunnelMain'; +import type { RouteFunnelProps, StepProps, StepType } from './useFunnel.type'; + +const useFunnel = (steps: Steps, initialStep: Steps[number]) => { + const location = useLocation(); + const navigate = useNavigate(); + + const setStep = (step: Steps[number]) => { + navigate(location.pathname, { + state: { + currentStep: step, + }, + }); + }; + + // 아직 헤더 디자인을 하지 않은 상태이기 때문에, goPrevStep은 사용하지 않는 상태입니다.(@해리) + const goPrevStep = () => { + navigate(-1); + }; + + const Step = ({ children }: StepProps) => { + return <>{children}; + }; + + // 컴포넌트가 다시 렌더링 될 때마다, Funnel 인스턴스가 다시 생성되는 문제가 있어서, useMemo로 감싸는 것으로 수정(@해리) + const Funnel = useMemo( + () => + Object.assign( + function RouteFunnel(props: RouteFunnelProps) { + const step = + (location.state as { currentStep?: Steps[number] })?.currentStep || initialStep; + + return steps={steps} step={step} {...props} />; + }, + { + Step, + }, + ), + [location.state, initialStep, steps], + ); + + return [setStep, Funnel] as const; +}; + +export default useFunnel; diff --git a/frontend/src/hooks/useFunnel/useFunnel.type.ts b/frontend/src/hooks/useFunnel/useFunnel.type.ts new file mode 100644 index 000000000..adf2fc844 --- /dev/null +++ b/frontend/src/hooks/useFunnel/useFunnel.type.ts @@ -0,0 +1,16 @@ +import type { ReactElement } from 'react'; + +export type StepType = Readonly>; + +export interface FunnelProps { + steps: Steps; + step: Steps[number]; + children: Array>>; +} + +export type RouteFunnelProps = Omit, 'steps' | 'step'>; + +export interface StepProps { + name: Steps[number]; + children: React.ReactNode; +} diff --git a/frontend/src/hooks/useInput/useInput.ts b/frontend/src/hooks/useInput/useInput.ts index 6aba728ee..4d1080315 100644 --- a/frontend/src/hooks/useInput/useInput.ts +++ b/frontend/src/hooks/useInput/useInput.ts @@ -35,4 +35,6 @@ const useInput = (rules?: ValidationRules) => { }; }; +export type UseInputReturn = ReturnType; + export default useInput; diff --git a/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.ts b/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.ts index b85287b24..0a6fb8dfe 100644 --- a/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.ts +++ b/frontend/src/hooks/useTimeRangeDropdown/useTimeRangeDropdown.ts @@ -8,7 +8,7 @@ import { isTimeSelectable, } from './useTimeRangeDropdown.utils'; -export default function useTimeRangeDropdown() { +const useTimeRangeDropdown = () => { const [startTime, setStartTime] = useState(INITIAL_START_TIME); const [endTime, setEndTime] = useState(INITIAL_END_TIME); @@ -39,4 +39,8 @@ export default function useTimeRangeDropdown() { handleStartTimeChange, handleEndTimeChange, } as const; -} +}; + +export type UseTimeRangeDropdownReturn = ReturnType; + +export default useTimeRangeDropdown; diff --git a/frontend/src/pages/CreateMeetingFunnelPage/MeetingDateTime.tsx b/frontend/src/pages/CreateMeetingFunnelPage/MeetingDateTime.tsx new file mode 100644 index 000000000..a71f7fb6b --- /dev/null +++ b/frontend/src/pages/CreateMeetingFunnelPage/MeetingDateTime.tsx @@ -0,0 +1,51 @@ +import TimeRangeSelector from '@components/TimeRangeSelector'; +import BottomFixedButton from '@components/_common/Buttons/BottomFixedButton'; +import Calendar from '@components/_common/Calendar'; +import Field from '@components/_common/Field'; + +import type { UseTimeRangeDropdownReturn } from '@hooks/useTimeRangeDropdown/useTimeRangeDropdown'; + +// 시작, 끝이 추가된 달력이랑 합쳐진 후, interface 수정예정(@해리) +interface MeetingDateInput { + hasDate: (date: string) => boolean; + onDateClick: (date: string) => void; +} + +interface MeetingDateTimeProps { + meetingDateInput: MeetingDateInput; + meetingTimeInput: UseTimeRangeDropdownReturn; + isCreateMeetingFormInvalid: boolean; + onMeetingCreateButtonClick: () => void; +} + +export default function MeetingDateTime({ + meetingDateInput, + meetingTimeInput, + isCreateMeetingFormInvalid, + onMeetingCreateButtonClick, +}: MeetingDateTimeProps) { + const { hasDate, onDateClick } = meetingDateInput; + const { startTime, endTime, handleStartTimeChange, handleEndTimeChange } = meetingTimeInput; + + return ( + <> + + + + + + + + + + + 약속 생성하기 + + + ); +} diff --git a/frontend/src/pages/CreateMeetingFunnelPage/MeetingHostInfo.tsx b/frontend/src/pages/CreateMeetingFunnelPage/MeetingHostInfo.tsx new file mode 100644 index 000000000..77493d6c7 --- /dev/null +++ b/frontend/src/pages/CreateMeetingFunnelPage/MeetingHostInfo.tsx @@ -0,0 +1,67 @@ +import BottomFixedButton from '@components/_common/Buttons/BottomFixedButton'; +import Field from '@components/_common/Field'; +import Input from '@components/_common/Input'; + +import useButtonOnKeyboard from '@hooks/useButtonOnKeyboard/useButtonOnKeyboard'; +import type { UseInputReturn } from '@hooks/useInput/useInput'; + +import { FIELD_DESCRIPTIONS } from '@constants/inputFields'; + +interface MeetingHostInfoProps { + hostNickNameInput: UseInputReturn; + hostPasswordInput: UseInputReturn; + isHostInfoInvalid: boolean; + onNextStep: () => void; +} + +export default function MeetingHostInfo({ + hostNickNameInput, + hostPasswordInput, + isHostInfoInvalid, + onNextStep, +}: MeetingHostInfoProps) { + const { + value: hostNickName, + onValueChange: handleHostNickNameChange, + errorMessage: hostNickNameErrorMessage, + } = hostNickNameInput; + const { + value: hostPassword, + onValueChange: handleHostPasswordChange, + errorMessage: hostPasswordErrorMessage, + } = hostPasswordInput; + + const resizedButtonHeight = useButtonOnKeyboard(); + + return ( + <> + + + + + + + + + + + + + + + 다음 + + + ); +} diff --git a/frontend/src/pages/CreateMeetingFunnelPage/MeetingName.tsx b/frontend/src/pages/CreateMeetingFunnelPage/MeetingName.tsx new file mode 100644 index 000000000..98ec588b2 --- /dev/null +++ b/frontend/src/pages/CreateMeetingFunnelPage/MeetingName.tsx @@ -0,0 +1,52 @@ +import BottomFixedButton from '@components/_common/Buttons/BottomFixedButton'; +import Field from '@components/_common/Field'; +import Input from '@components/_common/Input'; + +import useButtonOnKeyboard from '@hooks/useButtonOnKeyboard/useButtonOnKeyboard'; +import type { UseInputReturn } from '@hooks/useInput/useInput'; + +import { FIELD_DESCRIPTIONS } from '@constants/inputFields'; + +interface MeetingNameProps { + meetingNameInput: UseInputReturn; + isMeetingNameInvalid: boolean; + onNextStep: () => void; +} + +export default function MeetingName({ + meetingNameInput, + isMeetingNameInvalid, + onNextStep, +}: MeetingNameProps) { + const { + value: meetingName, + onValueChange: handleMeetingNameChange, + errorMessage: meetingNameErrorMessage, + } = meetingNameInput; + + const resizedButtonHeight = useButtonOnKeyboard(); + + return ( + <> + + + + + + + + 다음 + + + ); +} diff --git a/frontend/src/pages/CreateMeetingFunnelPage/index.tsx b/frontend/src/pages/CreateMeetingFunnelPage/index.tsx new file mode 100644 index 000000000..9f9874c37 --- /dev/null +++ b/frontend/src/pages/CreateMeetingFunnelPage/index.tsx @@ -0,0 +1,54 @@ +import MeetingDateTime from '@pages/CreateMeetingFunnelPage/MeetingDateTime'; +import MeetingHostInfo from '@pages/CreateMeetingFunnelPage/MeetingHostInfo'; +import MeetingName from '@pages/CreateMeetingFunnelPage/MeetingName'; + +import useCreateMeeting from '@hooks/useCreateMeeting/useCreateMeeting'; +import useFunnel from '@hooks/useFunnel/useFunnel'; + +import { CREATE_MEETING_STEPS, meetingStepValues } from '@constants/meeting'; + +type Steps = typeof meetingStepValues; + +export default function FunnelTestPage() { + const [setStep, Funnel] = useFunnel(meetingStepValues, '약속이름'); + const { + meetingNameInput, + isMeetingNameInvalid, + hostNickNameInput, + hostPasswordInput, + isHostInfoInvalid, + hasDate, + handleDateClick, + meetingTimeInput, + isCreateMeetingFormInvalid, + handleMeetingCreateButtonClick, + } = useCreateMeeting(); + + return ( + + + setStep(CREATE_MEETING_STEPS.meetingHostInfo)} + /> + + + setStep(CREATE_MEETING_STEPS.meetingDateTime)} + /> + + + + + + ); +} diff --git a/frontend/src/styles/global.ts b/frontend/src/styles/global.ts index ae46b9fc6..25ade3c17 100644 --- a/frontend/src/styles/global.ts +++ b/frontend/src/styles/global.ts @@ -104,7 +104,7 @@ const globalStyles = css` justify-content: center; width: 100%; - min-height: 100vh; + height: 100dvh; font-family: Pretendard, sans-serif; font-size: 1.6rem;