Skip to content

Commit bc9fafb

Browse files
authored
[#498] useFunnel 작성 (#501)
* feat: assert 유틸 함수 작성 * feat: useFunnel 커스텀 훅 작성 * feat: Funnel 컴포넌트 작성 * refactor: useFunnel 개선
1 parent 9131fdf commit bc9fafb

File tree

3 files changed

+139
-0
lines changed

3 files changed

+139
-0
lines changed

src/hooks/useFunnel.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use client';
2+
3+
import { useEffect, useMemo, useRef } from 'react';
4+
5+
import type { FunnelProps, StepProps } from '@/v1/base/Funnel/Funnel';
6+
import { assert } from '@/utils/assert';
7+
8+
import { Funnel, Step } from '@/v1/base/Funnel/Funnel';
9+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
10+
11+
export type NonEmptyArray<T> = readonly [T, ...T[]];
12+
13+
type RouteFunnelProps<Steps extends NonEmptyArray<string>> = Omit<
14+
FunnelProps<Steps>,
15+
'steps' | 'step'
16+
>;
17+
18+
type FunnelComponent<Steps extends NonEmptyArray<string>> = ((
19+
props: RouteFunnelProps<Steps>
20+
) => JSX.Element) & {
21+
Step: (props: StepProps<Steps>) => JSX.Element;
22+
};
23+
24+
const DEFAULT_STEP_QUERY_KEY = 'funnel-step';
25+
26+
/**
27+
* 사용자에게 초기 step을 강제하고 싶을 땐
28+
* option의 initialStep을 작성해 주세요.
29+
*/
30+
export const useFunnel = <Steps extends NonEmptyArray<string>>(
31+
steps: Steps,
32+
options?: {
33+
stepQueryKey?: string;
34+
initialStep?: Steps[number];
35+
onStepChange?: (name: Steps[number]) => void;
36+
}
37+
): readonly [FunnelComponent<Steps>, (step: Steps[number]) => void] => {
38+
const router = useRouter();
39+
const searchParams = useSearchParams();
40+
const pathname = usePathname();
41+
42+
const hasRunOnce = useRef(false);
43+
44+
const step = searchParams.get('funnel-step') as string;
45+
const stepQueryKey = options?.stepQueryKey ?? DEFAULT_STEP_QUERY_KEY;
46+
47+
useEffect(() => {
48+
if (options?.initialStep && !hasRunOnce.current) {
49+
hasRunOnce.current = true;
50+
router.replace(pathname);
51+
}
52+
}, [options?.initialStep, router, pathname]);
53+
54+
assert(steps.length > 0, 'steps가 비어있습니다.');
55+
56+
const FunnelComponent = useMemo(
57+
() =>
58+
Object.assign(
59+
function RouteFunnel(props: RouteFunnelProps<Steps>) {
60+
const currentStep = step ?? options?.initialStep;
61+
62+
assert(
63+
currentStep != null,
64+
`표시할 스텝을 ${stepQueryKey} 쿼리 파라미터에 지정해주세요. 쿼리 파라미터가 없을 때 초기 스텝을 렌더하려면 useFunnel의 두 번째 파라미터 options에 initialStep을 지정해주세요.`
65+
);
66+
67+
return <Funnel<Steps> steps={steps} step={currentStep} {...props} />;
68+
},
69+
{
70+
Step,
71+
}
72+
),
73+
// eslint-disable-next-line react-hooks/exhaustive-deps
74+
[step]
75+
);
76+
77+
const setStep = (step: Steps[number]) => {
78+
const params = new URLSearchParams(searchParams.toString());
79+
params.set('funnel-step', `${step}`);
80+
81+
return router.replace(`?${params.toString()}`);
82+
};
83+
84+
return [FunnelComponent, setStep] as unknown as readonly [
85+
FunnelComponent<Steps>,
86+
(step: Steps[number]) => Promise<void>
87+
];
88+
};

src/utils/assert.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const assert = (condition: unknown, error: Error | string) => {
2+
if (!condition) {
3+
if (typeof error === 'string') {
4+
throw new Error(error);
5+
} else {
6+
throw error;
7+
}
8+
}
9+
};

src/v1/base/Funnel/Funnel.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Children, ReactElement, ReactNode, isValidElement } from 'react';
2+
3+
import type { NonEmptyArray } from '@/hooks/useFunnel';
4+
5+
import { assert } from '@/utils/assert';
6+
7+
export interface FunnelProps<Steps extends NonEmptyArray<string>> {
8+
steps: Steps;
9+
step: Steps[number];
10+
children:
11+
| Array<ReactElement<StepProps<Steps>>>
12+
| ReactElement<StepProps<Steps>>;
13+
}
14+
15+
export const Funnel = <Steps extends NonEmptyArray<string>>({
16+
steps,
17+
step,
18+
children,
19+
}: FunnelProps<Steps>) => {
20+
const validChildren = Children.toArray(children)
21+
.filter(isValidElement)
22+
.filter(i =>
23+
steps.includes((i.props as Partial<StepProps<Steps>>).name ?? '')
24+
) as Array<ReactElement<StepProps<Steps>>>;
25+
26+
const targetStep = validChildren.find(child => child.props.name === step);
27+
28+
assert(targetStep != null, `${step} 스텝 컴포넌트를 찾지 못했습니다.`);
29+
30+
return <>{targetStep}</>;
31+
};
32+
33+
export interface StepProps<Steps extends NonEmptyArray<string>> {
34+
name: Steps[number];
35+
children: ReactNode;
36+
}
37+
38+
export const Step = <T extends NonEmptyArray<string>>({
39+
children,
40+
}: StepProps<T>) => {
41+
return <>{children}</>;
42+
};

0 commit comments

Comments
 (0)