-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[FE] 약속 생성 흐름에 퍼널 패턴 적용 #356
Changes from all commits
7465cc1
90e3693
d251414
caa868f
fa23d7d
56bb925
2ac567e
11d1186
0e7adc5
3c9f39c
2c3b09b
a0695ec
1a50636
08ff17c
8d81b1b
61ac806
301ebcd
9ee0f2c
ed17243
b237cd8
a0c1a03
b19834a
080f014
2c31e0d
2d58971
2faa1aa
5c5be28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
`; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<HTMLButtonElement> { | ||
children: React.ReactNode; | ||
height?: number; | ||
isLoading?: boolean; | ||
} | ||
|
||
export default function BottomFixedButton({ | ||
children, | ||
height = 0, | ||
isLoading = false, | ||
...props | ||
}: BottomFixedButtonProps) { | ||
return ( | ||
<div css={s_bottomFixedButtonContainer(height)}> | ||
<Button | ||
variant="primary" | ||
size="full" | ||
isLoading={isLoading} | ||
borderRadius={0} | ||
customCss={s_bottomFixedStyles} | ||
{...props} | ||
> | ||
{children} | ||
</Button> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export const CREATE_MEETING_STEPS = { | ||
meetingName: '약속이름', | ||
meetingHostInfo: '약속주최자정보', | ||
meetingDateTime: '약속날짜시간정보', | ||
} as const; | ||
|
||
export const meetingStepValues = Object.values(CREATE_MEETING_STEPS); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Q] 오호 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
setResizedButtonHeight(window.innerHeight - visualViewport.height); | ||
}; | ||
|
||
// 약속 이름 -> 약속 주최자 정보 입력으로 넘어갈 때 다음 버튼을 모바일 키보드로 올리기 위해서 resize 이벤트가 발생하지 않더라도 초기에 실행되도록 구현했어요.(@해리) | ||
handleButtonHeightResize(); | ||
visualViewport?.addEventListener('resize', handleButtonHeightResize); | ||
|
||
return () => { | ||
visualViewport?.removeEventListener('resize', handleButtonHeightResize); | ||
}; | ||
}, []); | ||
|
||
return resizedButtonHeight; | ||
}; | ||
|
||
export default useButtonOnKeyboard; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Set<string>>(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; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = <Steps extends StepType>( | ||
child: React.ReactNode, | ||
): child is ReactElement<StepProps<Steps>> => { | ||
return isValidElement(child) && typeof child.props.name === 'string'; | ||
}; | ||
|
||
export default function FunnelMain<Steps extends StepType>({ | ||
steps, | ||
step, | ||
children, | ||
}: FunnelProps<Steps>) { | ||
const childrenArray = Children.toArray(children) | ||
.filter(isValidFunnelChild<Steps>) | ||
.filter((child) => steps.includes(child.props.name)); | ||
|
||
const targetStep = childrenArray.find((child) => child.props.name === step); | ||
|
||
if (!targetStep) return null; | ||
|
||
return <>{targetStep}</>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 extends StepType>(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 = <Steps extends StepType>({ children }: StepProps<Steps>) => { | ||
return <>{children}</>; | ||
}; | ||
|
||
// 컴포넌트가 다시 렌더링 될 때마다, Funnel 인스턴스가 다시 생성되는 문제가 있어서, useMemo로 감싸는 것으로 수정(@해리) | ||
const Funnel = useMemo( | ||
() => | ||
Object.assign( | ||
function RouteFunnel(props: RouteFunnelProps<Steps>) { | ||
const step = | ||
(location.state as { currentStep?: Steps[number] })?.currentStep || initialStep; | ||
|
||
return <FunnelMain<Steps> steps={steps} step={step} {...props} />; | ||
}, | ||
{ | ||
Step, | ||
}, | ||
), | ||
[location.state, initialStep, steps], | ||
); | ||
|
||
return [setStep, Funnel] as const; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Q] 나중에 goPrevStep을 활용할 때는 return을 어떻게 구성하실 예정이신가요? 배열 형태이다보니 순서가 정해져있을 것 같아 궁금해 리뷰 남겨요! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 우선은 서비스에 뒤로가기 버튼이 없기 때문에, useState 훅 처럼 배열 형태로 반환하도록 구현한 후 돌아가는지 빠르게 확인하고 PR을 날렸습니다. 피드백을 빠르게 받는 것이 좋다고 판단했어요. 그래서 뒤로가기 버튼이 생긴다면 객체 형태로 반환할 것 같네요~ |
||
}; | ||
|
||
export default useFunnel; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import type { ReactElement } from 'react'; | ||
|
||
export type StepType = Readonly<Array<string>>; | ||
|
||
export interface FunnelProps<Steps extends StepType> { | ||
steps: Steps; | ||
step: Steps[number]; | ||
children: Array<ReactElement<StepProps<Steps>>>; | ||
} | ||
|
||
export type RouteFunnelProps<Steps extends StepType> = Omit<FunnelProps<Steps>, 'steps' | 'step'>; | ||
|
||
export interface StepProps<Steps extends StepType> { | ||
name: Steps[number]; | ||
children: React.ReactNode; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -35,4 +35,6 @@ const useInput = (rules?: ValidationRules) => { | |
}; | ||
}; | ||
|
||
export type UseInputReturn = ReturnType<typeof useInput>; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 해리가 반영해주셔서 너무 기쁘네요 🥹 |
||
|
||
export default useInput; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<> | ||
<Field> | ||
<Field.Label id="날짜선택" labelText="약속 날짜 선택" /> | ||
<Calendar hasDate={hasDate} onDateClick={onDateClick} /> | ||
</Field> | ||
|
||
<Field> | ||
<Field.Label id="약속시간범위선택" labelText="약속 시간 범위 선택" /> | ||
<TimeRangeSelector | ||
startTime={startTime} | ||
endTime={endTime} | ||
handleStartTimeChange={handleStartTimeChange} | ||
handleEndTimeChange={handleEndTimeChange} | ||
/> | ||
</Field> | ||
<BottomFixedButton onClick={onMeetingCreateButtonClick} disabled={isCreateMeetingFormInvalid}> | ||
약속 생성하기 | ||
</BottomFixedButton> | ||
</> | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Q]
높이를 6rem으로 설정해주신 이유가 있을까요? 저는 잘 몰랐는데 합의된 내용이 있다면 공유하면 좋을 것 같고, 합의된 내용이 없다면 해당 부분에 대한 컨벤션을 얘기 나누어보면 좋을 것 같아요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여기 이유를 작성했는데, 시각 자료가 부족했던 것 같군요
공통 버튼 컴포넌트의
full
variants를 사용하려고 하니, 이미지처럼 너무 버튼의 height가 작은 문제가 있다고 판단했습니다. 적절한 height를 찾던 중 6rem이 괜찮은 것 같아서 해당 속성 값을 사용하는 것으로 대체하게 되었습니다.저는 개인적으로 작업을 할 때, 중간중간 피드백을 받으면 서로의 작업에 영향을 줄 것 같아서 PR로 대화하는 것이 더 좋다고 생각하는 편인데 이런 관점에서는 중간중간 피드백을 받는게 나을수도 있겠다는 생각이 드네요. 어떻게 생각하시는지요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저는 언제든 피드백주셔도 좋습니다~! 해리가 편한 방법대로 하셔도 괜찮아요!
해리는 작업 중간에 대화가 오가는 것 보다 PR로 확인하는 것이 더 좋아보여서 중간 피드백보다는 반영 후 PR로 소통하는 편인 것으로 판단되는데, 저의 경우는 중간에 30번 찾아오셔도 괜찮습니다요~! 🙌