Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
38 changes: 14 additions & 24 deletions apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ const MacStep = lazy(() => import('./step/MacStep'));
const FinalStep = lazy(() => import('./step/FinalStep'));
import { cva } from 'class-variance-authority';
import { usePostSignUp } from '@shared/apis/queries';
import { useNavigate, useLocation } from 'react-router-dom';
import { firebaseConfig } from '../../../../firebase-config';
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken } from 'firebase/messaging';
import { registerServiceWorker } from '@pages/onBoarding/utils/registerServiceWorker';
import { AlarmsType } from '@constants/alarms';
import { normalizeTime } from '@pages/onBoarding/utils/formatRemindTime';
import { useFunnel } from '@shared/hooks/useFunnel';
const stepProgress = [{ progress: 33 }, { progress: 66 }, { progress: 100 }];
import {
Step,
Expand Down Expand Up @@ -50,11 +50,13 @@ const CardStyle = cva(
);

const MainCard = () => {
const navigate = useNavigate();
const location = useLocation();
const { mutate: postSignData } = usePostSignUp();

const [step, setStep] = useState<StepType>(Step.STORY_0);
const { currentStep: step, currentIndex, setStep, goNext, goPrev } =
useFunnel<StepType>({
steps: stepOrder,
initialStep: Step.STORY_0,
});
const [direction, setDirection] = useState(0);
const [alarmSelected, setAlarmSelected] = useState<1 | 2 | 3>(1);
const [isMac, setIsMac] = useState(false);
Expand All @@ -64,17 +66,11 @@ const MainCard = () => {
const [jobShareAgree, setJobShareAgree] = useState(true);

useEffect(() => {
const params = new URLSearchParams(location.search);
const storedEmail = localStorage.getItem('email');
if (storedEmail) {
setUserEmail(storedEmail);
}

const stepParam = params.get('step') as StepType;
if (stepParam && Object.values(Step).includes(stepParam)) {
setStep(stepParam);
}
}, [location.search]);
}, []);

const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);
Expand Down Expand Up @@ -154,8 +150,7 @@ const MainCard = () => {
};

const nextStep = async () => {
const idx = stepOrder.indexOf(step);
const next = stepOrder[idx + 1];
const next = stepOrder[currentIndex + 1];
const isAlarmStep = step === Step.ALARM;
const isFinalStep = step === Step.FINAL;
const isMacStep = next === Step.MAC;
Expand All @@ -173,7 +168,6 @@ const MainCard = () => {
if (shouldSkipMacStep) {
setDirection(1);
setStep(Step.FINAL);
navigate(`/onboarding?step=${Step.FINAL}`);
return;
}

Expand All @@ -192,22 +186,18 @@ const MainCard = () => {
}

setDirection(1);
setStep(next);
navigate(`/onboarding?step=${next}`);
goNext();
sendGAEvent(
`onboard-step-${idx + 1}`,
`onboard-step-${idx + 1}`,
`onboard-step-${idx + 1}`
`onboard-step-${currentIndex + 1}`,
`onboard-step-${currentIndex + 1}`,
`onboard-step-${currentIndex + 1}`
);
};

const prevStep = () => {
const idx = stepOrder.indexOf(step);
if (idx > 0) {
const previous = stepOrder[idx - 1];
if (currentIndex > 0) {
setDirection(-1);
setStep(previous);
navigate(`/onboarding?step=${previous}`);
goPrev();
}
};

Expand Down
69 changes: 69 additions & 0 deletions apps/client/src/shared/hooks/useFunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';

interface UseFunnelOptions<TStep extends string> {
steps: readonly TStep[];
initialStep: TStep;
queryKey?: string;
}

interface MoveStepOptions {
replace?: boolean;
}

export function useFunnel<TStep extends string>({
steps,
initialStep,
queryKey = 'step',
}: UseFunnelOptions<TStep>) {
const [searchParams, setSearchParams] = useSearchParams();

const currentStep = useMemo(() => {
const value = searchParams.get(queryKey);
return value && steps.includes(value as TStep)
? (value as TStep)
: initialStep;
}, [searchParams, queryKey, steps, initialStep]);
Comment on lines +14 to +26
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

initialStepsteps 배열에 포함되지 않을 경우 조용히 오동작

런타임에 initialStepsteps에 포함되어 있는지 검증하지 않습니다. 잘못 구성된 경우 currentIndex === -1이 되어 goNext()steps[0]으로 이동하는 등 예측 불가한 동작이 발생합니다.

🛡️ 개발 환경용 런타임 가드 예시
 export function useFunnel<TStep extends string>({
   steps,
   initialStep,
   queryKey = 'step',
 }: UseFunnelOptions<TStep>) {
+  if (process.env.NODE_ENV !== 'production' && !steps.includes(initialStep)) {
+    throw new Error(
+      `[useFunnel] initialStep "${initialStep}" is not in the steps array.`
+    );
+  }
+
   const [searchParams, setSearchParams] = useSearchParams();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/shared/hooks/useFunnel.ts` around lines 14 - 26, The hook
useFunnel fails to validate that the provided initialStep exists in steps which
can cause currentIndex === -1 and break functions like goNext; add a runtime
guard inside useFunnel that checks if initialStep is included in steps (use
Array.prototype.includes on steps) and if not either (a) in production: fall
back to a safe default (e.g., steps[0]) and log a clear error, or (b) in
development: throw or assert to surface the misconfiguration; update uses of
currentStep/currentIndex/goNext to rely on the validated value (e.g.,
initialStepValidated) so you never compute an index from an invalid initialStep.


const currentIndex = steps.indexOf(currentStep);

const setStep = useCallback(
(nextStep: TStep, { replace = true }: MoveStepOptions = {}) => {
if (!steps.includes(nextStep)) return;
const nextParams = new URLSearchParams(searchParams);
nextParams.set(queryKey, nextStep);
setSearchParams(nextParams, { replace });
},
[steps, searchParams, queryKey, setSearchParams]
);

const goNext = useCallback(
(options?: MoveStepOptions) => {
const nextStep = steps[currentIndex + 1];
if (!nextStep) return null;
setStep(nextStep, options);
return nextStep;
},
[steps, currentIndex, setStep]
);

const goPrev = useCallback(
(options?: MoveStepOptions) => {
const prevStep = steps[currentIndex - 1];
if (!prevStep) return null;
setStep(prevStep, options);
return prevStep;
},
[steps, currentIndex, setStep]
);

return {
currentStep,
currentIndex,
isFirstStep: currentIndex <= 0,
Copy link
Collaborator

Choose a reason for hiding this comment

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

isFirstStep의 currentIndex가 ===0이 아니라 <= 0로 한 이유가 뭔가요?

Copy link
Member Author

Choose a reason for hiding this comment

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

해당 로직은 음수의 index값이 나왔을 때에도 첫 번째 step으로 보도록 하는 방어적 로직에 가까워요.
물론 해당 로직은 첫 페이지로 강제 변환을 하지는 않고, isFirstStep에 대한 값만 조정하기는 합니다. 다만 현재 코드 상으로는 쿼리 step이 잘못되면 initialStep으로 대체하므로, initialStep만 정상이라면 -1와 같은 음수 값이 애초에 안나올 것 같기는 합니다!

isLastStep: currentIndex === steps.length - 1,
setStep,
goNext,
goPrev,
};
}
Loading