Skip to content
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
154 changes: 13 additions & 141 deletions apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Progress, Button, sendGAEvent } from '@pinback/design-system/ui';
import { useState, useEffect, lazy, Suspense } from 'react';
import { Progress, Button } from '@pinback/design-system/ui';
import { lazy, Suspense } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import SocialLoginStep from './step/SocialLoginStep';
const StoryStep = lazy(() => import('./step/StoryStep'));
Expand All @@ -8,21 +8,13 @@ const AlarmStep = lazy(() => import('./step/AlarmStep'));
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';
const stepProgress = [{ progress: 33 }, { progress: 66 }, { progress: 100 }];
import {
Step,
stepOrder,
StepType,
storySteps,
} from '@pages/onBoarding/constants/onboardingSteps';
import { useOnboardingFunnel } from '@pages/onBoarding/hooks/useOnboardingFunnel';

const variants = {
slideIn: (direction: number) => ({
Expand Down Expand Up @@ -50,78 +42,16 @@ const CardStyle = cva(
);

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

const [step, setStep] = useState<StepType>(Step.STORY_0);
const [direction, setDirection] = useState(0);
const [alarmSelected, setAlarmSelected] = useState<1 | 2 | 3>(1);
const [isMac, setIsMac] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [remindTime, setRemindTime] = useState('09:00');
const [fcmToken, setFcmToken] = useState<string | null>(null);
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);

const requestFCMToken = async (): Promise<string | null> => {
try {
const permission = await Notification.requestPermission();
registerServiceWorker();

if (permission !== 'granted') {
alert('알림 권한 허용이 필요합니다!');
return null;
}

const forFcmtoken = await getToken(messaging, {
vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
});

if (forFcmtoken) {
return forFcmtoken;
} else {
alert('토큰 생성 실패. 다시 시도해주세요.');
return null;
}
} catch (error) {
console.error('FCM 토큰 받는 도중 오류:', error);
alert('알림 설정 중 오류가 발생했습니다. 다시 시도해주세요.');
return null;
}
};

useEffect(() => {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('mac os') || ua.includes('iphone') || ua.includes('ipad')) {
setIsMac(true);
}

(async () => {
const token = await requestFCMToken();
if (token) {
setFcmToken(token);
localStorage.setItem('FcmToken', token);
} else {
alert('푸시 알람 설정 에러');
}
})();
}, []);
const {
step,
direction,
alarmSelected,
jobShareAgree,
setAlarmSelected,
setJobShareAgree,
nextStep,
prevStep,
} = useOnboardingFunnel();

const renderStep = () => {
switch (step) {
Expand Down Expand Up @@ -153,64 +83,6 @@ const MainCard = () => {
}
};

const nextStep = async () => {
const idx = stepOrder.indexOf(step);
const next = stepOrder[idx + 1];
const isAlarmStep = step === Step.ALARM;
const isFinalStep = step === Step.FINAL;
const isMacStep = next === Step.MAC;
const shouldSkipMacStep = isMacStep && !isMac;

if (isAlarmStep) {
if (alarmSelected === 1) setRemindTime('09:00');
else if (alarmSelected === 2) setRemindTime('20:00');
else {
const raw = AlarmsType[alarmSelected - 1].time;
setRemindTime(normalizeTime(raw));
}
}

if (shouldSkipMacStep) {
setDirection(1);
setStep(Step.FINAL);
navigate(`/onboarding?step=${Step.FINAL}`);
return;
}

if (isFinalStep) {
postSignData(
{ email: userEmail, remindDefault: remindTime, fcmToken },
{
onSuccess: () => (window.location.href = '/'),
onError: () => {
const savedEmail = localStorage.getItem('email');
if (savedEmail) window.location.href = '/';
},
}
);
return;
}

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

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

return (
<div
className={CardStyle({
Expand Down
157 changes: 157 additions & 0 deletions apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { sendGAEvent } from '@pinback/design-system/ui';
import { usePostSignUp } from '@shared/apis/queries';
import { useFunnel } from '@shared/hooks/useFunnel';
import { useCallback, useEffect, useState } from 'react';
import { AlarmsType } from '@constants/alarms';
import { normalizeTime } from '@pages/onBoarding/utils/formatRemindTime';
import { registerServiceWorker } from '@pages/onBoarding/utils/registerServiceWorker';
import { Step, stepOrder, StepType } from '@pages/onBoarding/constants/onboardingSteps';
import { firebaseConfig } from '../../../firebase-config';
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getMessaging, getToken } from 'firebase/messaging';

type AlarmSelection = 1 | 2 | 3;

export function useOnboardingFunnel() {
const { mutate: postSignData } = usePostSignUp();
const { currentStep: step, currentIndex, setStep, goNext, goPrev } =
useFunnel<StepType>({
steps: stepOrder,
initialStep: Step.STORY_0,
});

const [direction, setDirection] = useState(0);
const [alarmSelected, setAlarmSelected] = useState<AlarmSelection>(1);
const [isMac, setIsMac] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [remindTime, setRemindTime] = useState('09:00');
const [fcmToken, setFcmToken] = useState<string | null>(null);
const [jobShareAgree, setJobShareAgree] = useState(true);

useEffect(() => {
const storedEmail = localStorage.getItem('email');
if (storedEmail) {
setUserEmail(storedEmail);
}
}, []);

const requestFCMToken = useCallback(async (): Promise<string | null> => {
try {
const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig);
const messaging = getMessaging(app);
const permission = await Notification.requestPermission();
registerServiceWorker();

if (permission !== 'granted') {
alert('알림 권한 허용이 필요합니다!');
return null;
}

const forFcmtoken = await getToken(messaging, {
vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
});

if (forFcmtoken) {
return forFcmtoken;
}

alert('토큰 생성 실패. 다시 시도해주세요.');
return null;
} catch {
alert('알림 설정 중 오류가 발생했습니다. 다시 시도해주세요.');
return null;
}
}, []);
Comment on lines +38 to +64
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

registerServiceWorker()await 없이 호출되어 getToken과 경쟁 조건 발생 가능.

Line 43에서 registerServiceWorker()는 fire-and-forget으로 호출되지만, Line 50의 getToken은 서비스 워커가 등록 완료된 상태를 기대합니다. 서비스 워커 등록이 완료되기 전에 getToken이 호출되면 토큰 생성이 실패할 수 있습니다.

또한 Line 66-80의 useEffect에서 컴포넌트 마운트 즉시 FCM 토큰을 요청하면서 알림 권한 프롬프트가 사용자 의도 없이 표시됩니다. 이는 브라우저의 권한 정책에 의해 이후 요청이 차단될 수 있습니다.

🔧 서비스 워커 등록 후 토큰 요청 순서 보장 제안
- const requestFCMToken = useCallback(async (): Promise<string | null> => {
+ const requestFCMToken = useCallback(async (swRegistration?: ServiceWorkerRegistration): Promise<string | null> => {
     try {
       const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig);
       const messaging = getMessaging(app);
       const permission = await Notification.requestPermission();
-      registerServiceWorker();

       if (permission !== 'granted') {
         alert('알림 권한 허용이 필요합니다!');
         return null;
       }

       const forFcmtoken = await getToken(messaging, {
         vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
+        serviceWorkerRegistration: swRegistration,
       });

useEffect 내에서 서비스 워커를 먼저 등록한 후 registration 객체를 requestFCMToken에 전달하는 방식을 권장합니다.

Also applies to: 66-81

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts` around lines
38 - 64, requestFCMToken currently calls registerServiceWorker() without
awaiting it causing a race with getToken; change requestFCMToken to await
service worker registration (or accept a ServiceWorkerRegistration parameter)
and pass that registration to getToken (use the messaging registration option)
so token retrieval happens after registration completes; also modify the
useEffect that triggers requestFCMToken on mount to avoid prompting for
notification permission immediately (invoke requestFCMToken only in response to
a user action or after explicit consent) and update any callers to provide the
registration if you switched to the parameter approach; refer to
requestFCMToken, registerServiceWorker, and the mounting useEffect when making
these changes.


useEffect(() => {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('mac os') || ua.includes('iphone') || ua.includes('ipad')) {
setIsMac(true);
}

(async () => {
const token = await requestFCMToken();
if (token) {
setFcmToken(token);
localStorage.setItem('FcmToken', token);
} else {
alert('푸시 알람 설정 에러');
}
})();
}, [requestFCMToken]);

const nextStep = useCallback(async () => {
const next = stepOrder[currentIndex + 1];
const isAlarmStep = step === Step.ALARM;
const isFinalStep = step === Step.FINAL;
const isMacStep = next === Step.MAC;
const shouldSkipMacStep = isMacStep && !isMac;

if (isAlarmStep) {
if (alarmSelected === 1) setRemindTime('09:00');
else if (alarmSelected === 2) setRemindTime('20:00');
else {
const raw = AlarmsType[alarmSelected - 1].time;
setRemindTime(normalizeTime(raw));
}
Comment on lines +90 to +96
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

fd "alarms.ts" --type f

Repository: Pinback-Team/pinback-client

Length of output: 106


🏁 Script executed:

fd "useOnboardingFunnel.ts" --type f

Repository: Pinback-Team/pinback-client

Length of output: 132


🏁 Script executed:

rg "normalizeTime" --type ts --type tsx -A 10

Repository: Pinback-Team/pinback-client

Length of output: 98


🏁 Script executed:

rg "normalizeTime" --type ts -A 10

Repository: Pinback-Team/pinback-client

Length of output: 5227


🏁 Script executed:

cat apps/client/src/constants/alarms.ts

Repository: Pinback-Team/pinback-client

Length of output: 545


🏁 Script executed:

cat -n apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts | sed -n '85,100p'

Repository: Pinback-Team/pinback-client

Length of output: 682


🏁 Script executed:

cat apps/client/src/pages/onBoarding/utils/formatRemindTime.ts

Repository: Pinback-Team/pinback-client

Length of output: 749


🏁 Script executed:

cat apps/client/src/constants/alarms.ts

Repository: Pinback-Team/pinback-client

Length of output: 545


🏁 Script executed:

cat -n apps/client/src/pages/onBoarding/components/funnel/AlarmBox.tsx | grep -A 50 "select === 3"

Repository: Pinback-Team/pinback-client

Length of output: 1465


🏁 Script executed:

cat -n apps/client/src/pages/onBoarding/components/funnel/AlarmBox.tsx | sed -n '1,50p'

Repository: Pinback-Team/pinback-client

Length of output: 1866


🏁 Script executed:

rg "getTimePicker" apps/client/src/pages/onBoarding/components/funnel/AlarmBox.tsx -A 5 -B 5

Repository: Pinback-Team/pinback-client

Length of output: 567


🏁 Script executed:

rg "AlarmStep" --type ts -A 30 | head -80

Repository: Pinback-Team/pinback-client

Length of output: 7183


🏁 Script executed:

cat -n apps/client/src/pages/onBoarding/components/funnel/AlarmStep.tsx

Repository: Pinback-Team/pinback-client

Length of output: 167


🏁 Script executed:

fd "AlarmStep" --type f

Repository: Pinback-Team/pinback-client

Length of output: 140


🏁 Script executed:

rg "AlarmsType\[2\].time =" --type ts -B 5 -A 5

Repository: Pinback-Team/pinback-client

Length of output: 1076


🏁 Script executed:

cat -n apps/client/src/pages/onBoarding/components/funnel/step/AlarmStep.tsx

Repository: Pinback-Team/pinback-client

Length of output: 1556


🏁 Script executed:

rg "remindTime" apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts -B 2 -A 2

Repository: Pinback-Team/pinback-client

Length of output: 598


alarmSelected === 3일 때 사용자 설정 시간을 선택하지 않은 경우 normalizeTime에 빈 문자열이 전달되어 'NaN:NaN'이 설정됩니다.

AlarmsType[2].time은 초기값이 ''(빈 문자열)입니다. 사용자가 선택지 3(사용자 설정)을 선택했지만 TimePicker를 통해 시간을 지정하지 않은 채로 다음 단계로 진행하면, normalizeTime('')이 호출됩니다. 이 함수는 빈 문자열을 분할하여 parseInt('', 10)NaN을 반환하므로, remindTime'NaN:NaN'으로 설정되어 백엔드로 전송됩니다.

시간 선택 검증을 추가하거나, 빈 문자열 처리를 추가해야 합니다.

🐛 빈 문자열 가드 추가 제안
     } else {
       const raw = AlarmsType[alarmSelected - 1].time;
-      setRemindTime(normalizeTime(raw));
+      if (raw) {
+        setRemindTime(normalizeTime(raw));
+      }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isAlarmStep) {
if (alarmSelected === 1) setRemindTime('09:00');
else if (alarmSelected === 2) setRemindTime('20:00');
else {
const raw = AlarmsType[alarmSelected - 1].time;
setRemindTime(normalizeTime(raw));
}
if (isAlarmStep) {
if (alarmSelected === 1) setRemindTime('09:00');
else if (alarmSelected === 2) setRemindTime('20:00');
else {
const raw = AlarmsType[alarmSelected - 1].time;
if (raw) {
setRemindTime(normalizeTime(raw));
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts` around lines
90 - 96, When handling alarmSelected in useOnboardingFunnel, avoid calling
normalizeTime on an empty AlarmsType entry: check the computed raw =
AlarmsType[alarmSelected - 1].time for falsy/empty string before calling
normalizeTime; if raw is empty, do not call normalizeTime or set remindTime to a
safe default (e.g., '09:00') or trigger validation so you don't set remindTime
to 'NaN:NaN'; update the branch around isAlarmStep / alarmSelected to guard raw,
call normalizeTime(raw) only when raw is non-empty, and use setRemindTime with
the fallback or validation flow otherwise.

}

if (shouldSkipMacStep) {
setDirection(1);
setStep(Step.FINAL);
return;
}

if (isFinalStep) {
postSignData(
{ email: userEmail, remindDefault: remindTime, fcmToken },
{
onSuccess: () => (window.location.href = '/'),
onError: () => {
const savedEmail = localStorage.getItem('email');
if (savedEmail) window.location.href = '/';
},
}
);
return;
}
Comment on lines +105 to +117
Copy link

@coderabbitai coderabbitai bot Feb 21, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for postSignData API type definition and fcmToken usage
rg -n "postSignData|postSignUp" --type ts -C 3 | head -100

Repository: Pinback-Team/pinback-client

Length of output: 3800


🏁 Script executed:

#!/bin/bash
# Find fcmToken initialization around Line 28
rg -n "fcmToken" apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts -C 5

Repository: Pinback-Team/pinback-client

Length of output: 1214


🏁 Script executed:

#!/bin/bash
# Look for the API request type definition
fd -e ts -e tsx | xargs rg -l "fcmToken" | head -10

Repository: Pinback-Team/pinback-client

Length of output: 202


🏁 Script executed:

#!/bin/bash
# Check how fcmToken is set - look for requestFcmToken or similar
rg -n "setFcmToken" apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts -C 10

Repository: Pinback-Team/pinback-client

Length of output: 1481


🏁 Script executed:

#!/bin/bash
# Check if there are any FCM token request functions
rg -n "fcmToken|FCM|firebase" apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts -A 2

Repository: Pinback-Team/pinback-client

Length of output: 1122


🏁 Script executed:

#!/bin/bash
# Check the complete onError handler and context
sed -n '105,120p' apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts

Repository: Pinback-Team/pinback-client

Length of output: 480


🏁 Script executed:

#!/bin/bash
# Search for any error notifications or logging in the file
rg -n "toast|console|alert|error" apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts

Repository: Pinback-Team/pinback-client

Length of output: 231


onError 핸들러에서 회원가입 실패를 무시하고 사용자에게 성공한 것처럼 보이게 합니다.

postSignData 실패 시 onError 핸들러(Line 110-113)가 savedEmail이 존재하면 에러를 무시하고 홈으로 리다이렉트합니다. 이는 회원가입이 실패했음에도 사용자에게 성공한 것처럼 보여 혼동을 야기할 수 있습니다. 에러 발생 시 사용자에게 알림을 표시하고 적절하게 처리해야 합니다.

참고: fcmTokennull일 수 있는 것은 API 타입에서 의도적으로 허용(fcmToken: string | null)하고 있으므로 설계상 문제는 아니지만, FCM 요청 실패 시 사용자 경험을 개선할 필요가 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/pages/onBoarding/hooks/useOnboardingFunnel.ts` around lines
105 - 117, The onError block in the postSignData call inside useOnboardingFunnel
(the isFinalStep branch) incorrectly treats failures as success by redirecting
when a savedEmail exists; change onError to surface the failure instead of
silently redirecting: remove the conditional redirect, capture the error from
the postSignData onError callback and set a visible error state or call the
existing UI notification/toast utility (or expose setError via the hook) so the
UI can show an error message and let the user retry; keep the successful
onSuccess redirect intact and ensure fcmToken null handling remains unchanged.

Copy link
Member Author

Choose a reason for hiding this comment

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

에러 fallback은 이후에 추가 예정

Copy link

Choose a reason for hiding this comment

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

@constantly-dev, 알겠습니다! 이후 PR에서 에러 fallback을 추가하실 계획이시군요.

이 작업을 추적하기 위해 GitHub 이슈를 생성해 드릴까요? 그러면 나중에 놓치지 않고 처리하실 수 있을 것 같습니다.


🧠 Learnings used
Learnt from: constantly-dev
Repo: Pinback-Team/pinback-client PR: 2
File: pnpm-workspace.yaml:3-3
Timestamp: 2025-08-18T13:48:59.065Z
Learning: constantly-dev는 docs 디렉터리를 컨벤션 문서 추가용으로 사용할 예정이라고 명시했습니다.

Learnt from: constantly-dev
Repo: Pinback-Team/pinback-client PR: 5
File: apps/extension/src/index.css:1-1
Timestamp: 2025-08-19T17:18:57.678Z
Learning: constantly-dev는 디자인 시스템 설정 PR 머지 후 `import 'pinback/tailwind-config/shared-styles.css';`를 `app.css`나 `index.css`에서 사용하여 공유 스타일을 관리할 계획입니다.


setDirection(1);
goNext();
sendGAEvent(
`onboard-step-${currentIndex + 1}`,
`onboard-step-${currentIndex + 1}`,
`onboard-step-${currentIndex + 1}`
);
}, [
alarmSelected,
currentIndex,
fcmToken,
goNext,
isMac,
postSignData,
remindTime,
setStep,
step,
userEmail,
]);

const prevStep = useCallback(() => {
if (currentIndex > 0) {
setDirection(-1);
goPrev();
}
}, [currentIndex, goPrev]);

return {
step,
currentIndex,
direction,
alarmSelected,
jobShareAgree,
setAlarmSelected,
setJobShareAgree,
nextStep,
prevStep,
};
}
Loading
Loading