Skip to content
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

[온보딩] funnel 이용하여 사용자 정보 수집 로직 #68

Merged
merged 16 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { RouterProvider } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import router from './router/Router';
import GlobalStyle from './style/GlobalStyle';
import styled from 'styled-components';

function App() {
const [queryClient] = useState(
Expand All @@ -17,13 +18,25 @@ function App() {
}),
);
return (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<RouterProvider router={router} />
<GlobalStyle />
</RecoilRoot>
</QueryClientProvider>
<Wrapper>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 고민해보니까 Layout에서 전체 모바일뷰 설정해주는 게 좋긴 한 것 같아서 Layout에 min-height를 지정하거나 Layout과 겹치는 속성을 Wrapper에서 제거하는 식으로 main으로 머지 전에 수정하면 좋을 것 같다는 개인적인 생각이 들었어요..!

<QueryClientProvider client={queryClient}>
<RecoilRoot>
<RouterProvider router={router} />
<GlobalStyle />
</RecoilRoot>
</QueryClientProvider>
</Wrapper>
);
}

export default App;

const Wrapper = styled.div`
background-color: white;
border: none;
min-height: calc(var(--vh, 1vh) * 100);
/* min-width: var(--app-max-width, 375px); */
margin-left: auto;
margin-right: auto;
position: relative;
`;
1 change: 0 additions & 1 deletion src/assets/svg/IcCancelCircleFinal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as React from 'react';
import type { SVGProps } from 'react';
const SvgIcCancelCircleFinal = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' {...props}>
Expand Down
35 changes: 35 additions & 0 deletions src/components/common/Funnel/Funnel.tsx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와 정말 어렵네요.. 구조 파악이 정말 어려웠어요..! 근데 라이브러리 사용보니 저희 온보딩에 진짜 적합한 라이브러리군요..!

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Children, isValidElement, PropsWithChildren, ReactElement, useEffect } from 'react';
import { NonEmptyArray } from '../../../types/utility';
import { assert } from '../../../../utils/errors';

export interface FunnelProps<Steps extends NonEmptyArray<string>> {
steps: Steps;
step: Steps[number];
children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
Comment on lines +6 to +8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 정말 그냥 의견제시인데 지금 그럼 steps가 스텝을 구별하는 카테고리고
step이 index값이라면 좀더 명확하게 steps, step 이 좀 비슷한 것 같아서 네이밍 변경을 추후에 리팩토링 할 때 해보면 좋을 것 같아요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞아요 그래서 레퍼런스 보면 steps를 그냥 일반적인 typescript에서 사용하는 T를 사용해서 구현했더라구요! 가영님이 알려준 방식이 훨씬 가독성이 좋을 것 같습니다!! 저도 긁어오면서 굉장히 헷갈렸거든요 ㅠ

}

export interface StepProps<Steps extends NonEmptyArray<string>> extends PropsWithChildren {
name: Steps[number];
onNext?: VoidFunction;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 저는 () => void만 썼었는데 VoidFunction으로 쓸 수 있군요! 덕분에 배워갑니다~!
차이를 몰랐었는데 찾아보니 TypeScript의 내장 유틸리티 타입이라서 VoidFunction이 더 명시적이고 가독성이 좋다고 하네요! 최고최고👍

}

export const Funnel = <Steps extends NonEmptyArray<string>>(props: FunnelProps<Steps>) => {
const { steps, step, children } = props;
const validChildren = Children.toArray(children)
.filter(isValidElement<StepProps<Steps>>)
.filter(({ props }) => steps.includes(props.name));

const targetStep = validChildren.find((child) => child.props.name === step);

assert(targetStep != null, `${step} 스텝 컴포넌트를 찾지 못했습니다.`);

return targetStep;
};

export const Step = <T extends NonEmptyArray<string>>({ onNext, children }: StepProps<T>) => {
useEffect(() => {
onNext?.();
}, [onNext]);

return children;
};
21 changes: 19 additions & 2 deletions src/components/onboarding/step01/Step01.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ import { useState } from 'react';
import Title from '../../common/title/Title';
import * as S from './Step01.style';
import { IcCancelCircleFinal } from '../../../assets/svg';
import BtnNext from '../../common/Button/Next/BtnNext';

const NameInput = () => {
interface NameInputProps {
onNext: VoidFunction;
}

const NameInput = (props: NameInputProps) => {
const { onNext } = props;
const [text, setText] = useState<string>('');

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
Expand All @@ -13,7 +19,6 @@ const NameInput = () => {
inputValue.length + unicodeChars <= 10 ? setText(inputValue) : e.preventDefault();
};


const handleBtnClick = () => {
setText('');
};
Expand Down Expand Up @@ -42,6 +47,18 @@ const NameInput = () => {
</S.IconField>
</S.Wrapper>
<S.LetterLength>({text.length}/10)</S.LetterLength>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스타일 컴포넌트를 적용하면 더 가독성이 좋을 것 같아요~!

<BtnNext
type='button'
onClick={onNext}
customStyle={{
position: 'absolute',
bottom: '0',
}}
>
Comment on lines +50 to +58
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저 방금 리액트 모범사례 읽으면서 인라이 스타일 지양해야한다! 는 글을 읽고 왔습니다. 저도 이렇게 인라인 스타일썼는데 같이 모듈화해서 리팩토링 합시다!
스크린샷 2024-01-13 오후 11 42 21

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 인라인 보다 확실히 이런 방법이 좋겠네요. 아무래도 좀 가독성이 떨어져서 지금...

다음
</BtnNext>
</div>
</>
);
};
Expand Down
1 change: 1 addition & 0 deletions src/components/onboarding/step02/Step02.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export const ThumbnailWrapper = styled.div`
width: 24rem;
height: 24rem;
margin: 0 auto;
margin-top: 6.1rem;
Comment on lines 8 to +9
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

margin 설정이 두 번 들어가서 한 번 확인 부탁드려요!

`;
23 changes: 21 additions & 2 deletions src/components/onboarding/step02/Step02.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import Title from '../../common/title/Title';
import { IcPlusImageFinal } from '../../../assets/svg';
import * as S from './Step02.style';
import BtnNext from '../../common/Button/Next/BtnNext';

const ThumbnailInput = () => {
interface ThumbnailInputProps {
onNext: VoidFunction;
}

const ThumbnailInput = (props: ThumbnailInputProps) => {
// TODO 이미지 클릭 시 사진 업로드
const { onNext } = props;

return (
<>
<Title title='썸네일을 등록해주세요' />
<div style={{ width: '100%', marginTop: '11rem' }}>
<div style={{ width: '100%' }}>
<S.ThumbnailWrapper>
<IcPlusImageFinal style={{ width: '5rem', height: '5rem' }} />
</S.ThumbnailWrapper>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<BtnNext
type='button'
onClick={onNext}
customStyle={{
position: 'absolute',
bottom: '0',
}}
>
다음
</BtnNext>
</div>
</div>
</>
);
Expand Down
22 changes: 21 additions & 1 deletion src/components/onboarding/step03/Step03.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import Title from '../../common/title/Title';
import { IcUnselectedCalender } from '../../../assets/svg';
import * as S from './Step03.style';
import BtnNext from '../../common/Button/Next/BtnNext';

interface GiftDeliveryProps {
onNext: VoidFunction;
}

const GiftDelivery = (props: GiftDeliveryProps) => {
const { onNext } = props;

const GiftDelivery = () => {
return (
<>
{/* TODO 추후 로그인된 유저네임으로 변경 및 인풋창 클릭 시 켈린더 호출*/}
Expand All @@ -17,6 +24,19 @@ const GiftDelivery = () => {
<IcUnselectedCalender style={{ width: '2.4rem', height: '2.4rem' }} />
</S.IconField>
</S.Wrapper>

<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<BtnNext
type='button'
onClick={onNext}
customStyle={{
position: 'absolute',
bottom: '0',
}}
>
다음
</BtnNext>
</div>
</>
);
};
Expand Down
21 changes: 20 additions & 1 deletion src/components/onboarding/step04/Step04.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import SubTitle from '../../common/title/SubTitle';
import Title from '../../common/title/Title';
import { IcUnselectedCalender, IcUnselectedClock } from '../../../assets/svg';
import * as S from './Step04.style';
import BtnNext from '../../common/Button/Next/BtnNext';

const SetTournamentSchedule = () => {
interface SetTournamentScheduleProps {
onNext: VoidFunction;
}

const SetTournamentSchedule = (props: SetTournamentScheduleProps) => {
// TODO 인풋창 클릭 시 캘린더 & 시간 선택 창 구현
const { onNext } = props;

return (
<>
<div>
Expand Down Expand Up @@ -32,6 +39,18 @@ const SetTournamentSchedule = () => {
<IcUnselectedClock style={{ width: '2.4rem', height: '2.4rem' }} />
</S.IconField>
</S.Container>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<BtnNext
type='button'
onClick={onNext}
customStyle={{
position: 'absolute',
bottom: '0',
}}
>
다음
</BtnNext>
</div>
</>
);
};
Expand Down
21 changes: 19 additions & 2 deletions src/components/onboarding/step05/Step05.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import BtnNext from '../../common/Button/Next/BtnNext';
import SubTitle from '../../common/title/SubTitle';
import Title from '../../common/title/Title';
import * as S from './Step05.style';
import TimeBox from './TimeBox';

const SetTournamentDuration = () => {
// TODO 오늘 기준 날짜로 수정 & map함수로 수정
interface SetTournamentDurationProps {
onNext: VoidFunction;
}

const SetTournamentDuration = (props: SetTournamentDurationProps) => {
// TODO 오늘 기준 날짜로 수정 & map함수로 수정
const { onNext } = props;
return (
<>
<div>
Expand All @@ -18,6 +23,18 @@ const SetTournamentDuration = () => {
<S.SetTournamentDurationWrapper>
<TimeBox />
</S.SetTournamentDurationWrapper>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<BtnNext
type='button'
onClick={onNext}
customStyle={{
position: 'absolute',
bottom: '0',
}}
>
다음
</BtnNext>
</div>
</>
);
};
Expand Down
1 change: 1 addition & 0 deletions src/core/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export const ONBOARDING_FORM_STEP = [
'PRESENT',
'TOURNAMENT_SCHEDULE_REGISTRATION',
'TOURNAMENT_PROCEEDING',
'GIFT_ROOM_FIX',
] as const;
23 changes: 23 additions & 0 deletions src/hooks/useFunnel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useMemo, useState } from 'react';
import { NonEmptyArray } from '../types/utility';
import { Funnel, FunnelProps, Step } from '../components/common/Funnel/Funnel';

export const useFunnel = <Steps extends NonEmptyArray<string>>(
steps: Steps,
defaultStep: Steps[number],
) => {
const [step, setStep] = useState<Steps[number]>(defaultStep);

const FunnelComponent = useMemo(
() =>
Object.assign(
(props: Omit<FunnelProps<Steps>, 'step' | 'steps'>) => (
<Funnel<Steps> step={step} steps={steps} {...props} />
),
{ Step },
),
[step],
);
Comment on lines +11 to +20
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useMemo 사용 너무 좋습니다.. Omit 대박이네요..시간이 되면 더 뜯어보고 싶은 코드네요

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Omit이 일단 간지가 좀 나보여서... ㅋㅋㅋㅋㅋㅋ 저도 라이브러리 뜯어보면서 배운거라 제가 구현했다고 하기에는 좀 그러네요.
토스가 한겁니다!!~


return { Funnel: FunnelComponent, step, setStep };
};
26 changes: 25 additions & 1 deletion src/pages/OnBoardingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
//온보딩 모든 컴포넌트를 funnel로 관리하는 최상위 페이지

import styled from 'styled-components';
import { ONBOARDING_FORM_STEP } from '../core/onboarding';
import { useFunnel } from '../hooks/useFunnel';
import NameInput from '../components/onboarding/step01/Step01';
import ThumbnailInput from '../components/onboarding/step02/Step02';
import GiftDelivery from '../components/onboarding/step03/Step03';
import SetTournamentSchedule from '../components/onboarding/step04/Step04';
import SetTournamentDuration from '../components/onboarding/step05/Step05';

const OnBoardingPage = () => {
const { Funnel, setStep } = useFunnel(ONBOARDING_FORM_STEP, ONBOARDING_FORM_STEP[0]);

return (
<OnBoardingPageWrapper>
<NameInput />
<Funnel>
<Funnel.Step name='NAME'>
<NameInput onNext={() => setStep(() => 'THUMBNAIL')} />
</Funnel.Step>
Comment on lines +18 to +20
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오오 이렇게 쓰는 거군요! 대박

<Funnel.Step name='THUMBNAIL'>
<ThumbnailInput onNext={() => setStep(() => 'PRESENT')} />
</Funnel.Step>
<Funnel.Step name='PRESENT'>
<GiftDelivery onNext={() => setStep(() => 'TOURNAMENT_SCHEDULE_REGISTRATION')} />
</Funnel.Step>
<Funnel.Step name='TOURNAMENT_SCHEDULE_REGISTRATION'>
<SetTournamentSchedule onNext={() => setStep(() => 'TOURNAMENT_PROCEEDING')} />
</Funnel.Step>
<Funnel.Step name='TOURNAMENT_PROCEEDING'>
<SetTournamentDuration onNext={() => setStep(() => 'GIFT_ROOM_FIX')} />
</Funnel.Step>
</Funnel>
</OnBoardingPageWrapper>
);
};
Expand Down
1 change: 1 addition & 0 deletions src/types/utility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type NonEmptyArray<T> = readonly [T, ...T[]];
9 changes: 9 additions & 0 deletions utils/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function assert(condition: unknown, error: Error | string = new Error()): asserts condition {
if (!condition) {
if (typeof error === 'string') {
throw new Error(error);
} else {
throw error;
}
}
}