diff --git a/src/apis/member/requestPhoneVerification.api.ts b/src/apis/member/requestPhoneVerification.api.ts index 1d6f1b8e..d6f531d8 100644 --- a/src/apis/member/requestPhoneVerification.api.ts +++ b/src/apis/member/requestPhoneVerification.api.ts @@ -17,7 +17,7 @@ export async function requestPhoneVerification( const body = res.data; if (!body.success) { - throw new Error(body.message); + throw Object.assign(new Error(body.message), { code: body.code }); } return body; diff --git a/src/assets/icon/index.ts b/src/assets/icon/index.ts index 316ef920..611197b4 100644 --- a/src/assets/icon/index.ts +++ b/src/assets/icon/index.ts @@ -54,6 +54,7 @@ export { default as RestoraionSparkleIcon } from "./restoration-sparkle.svg?reac export { default as TooltipXIcon } from "./tooltip-x.svg?react"; export { default as AiRestoreIcon } from "./ai-restore.svg?react"; export { default as FindersLogoFooterIcon } from "./finders-logo-footer.svg?react"; +export { default as PolygonIcon } from "./polygon.svg?react"; // 소셜 / 브랜드 export { default as AppleIcon } from "./apple.svg?react"; diff --git a/src/assets/icon/polygon.svg b/src/assets/icon/polygon.svg new file mode 100644 index 00000000..064f664c --- /dev/null +++ b/src/assets/icon/polygon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/auth/RecentLoginDialog.tsx b/src/components/auth/RecentLoginDialog.tsx new file mode 100644 index 00000000..92f4b709 --- /dev/null +++ b/src/components/auth/RecentLoginDialog.tsx @@ -0,0 +1,16 @@ +import { PolygonIcon } from "@/assets/icon"; + +interface RecentLoginDialogProps { + className?: string; +} + +export function RecentLoginDialog({ className = "" }: RecentLoginDialogProps) { + return ( +
+
+ 최근에 로그인 했어요 +
+ +
+ ); +} diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts index 6c44ab9d..20e3807b 100644 --- a/src/components/auth/index.ts +++ b/src/components/auth/index.ts @@ -5,3 +5,4 @@ export { ActionButton } from "./ActionButton"; export { TermsAgreementItem } from "./TermsAgreementItem"; export { TermsContent } from "./TermsContent"; export { TermsAccordionRow } from "./TermsAccordionRow"; +export { RecentLoginDialog } from "./RecentLoginDialog"; diff --git a/src/constants/member/phone.constant.ts b/src/constants/member/phone.constant.ts new file mode 100644 index 00000000..9528d527 --- /dev/null +++ b/src/constants/member/phone.constant.ts @@ -0,0 +1,2 @@ +// 서버 ErrorCode: 인증번호 발송 시 이미 가입된 전화번호 +export const PHONE_ALREADY_REGISTERED_CODE = "MEMBER_412"; diff --git a/src/hooks/auth/login/useAppleLogin.ts b/src/hooks/auth/login/useAppleLogin.ts index 280eeab9..7e94f14a 100644 --- a/src/hooks/auth/login/useAppleLogin.ts +++ b/src/hooks/auth/login/useAppleLogin.ts @@ -1,5 +1,9 @@ import { useState } from "react"; import { loginWithApple } from "@/utils/auth/appleSdk"; +import { + setPendingSignupProvider, + setRecentLoginProvider, +} from "@/utils/auth/recentLoginProvider"; import { tokenStorage } from "@/utils/tokenStorage"; import { useOauth } from "./useOauth"; import { useAuthStore } from "@/store/useAuth.store"; @@ -26,6 +30,8 @@ export function useAppleLogin({ const data = res.data; if ("accessToken" in data) { + // 가입이 끝난 계정만 "최근 로그인"으로 기록 (신규 회원은 아직 계정이 없음) + setRecentLoginProvider("APPLE"); tokenStorage.setTokens({ accessToken: data.accessToken, signupToken: null, @@ -33,6 +39,7 @@ export function useAppleLogin({ setUser({ memberId: data.member.id, nickname: data.member.nickname }); onExistingMember(); } else { + setPendingSignupProvider("APPLE"); tokenStorage.setTokens({ accessToken: null, signupToken: data.signupToken, diff --git a/src/hooks/auth/login/useKakaoOAuth.ts b/src/hooks/auth/login/useKakaoOAuth.ts index 1cd93c28..e67a4d75 100644 --- a/src/hooks/auth/login/useKakaoOAuth.ts +++ b/src/hooks/auth/login/useKakaoOAuth.ts @@ -1,6 +1,10 @@ import { useEffect, useRef } from "react"; import { useSearchParams } from "react-router"; import { consumeAndValidateKakaoState } from "@/utils/auth/kakaoOauth"; +import { + setPendingSignupProvider, + setRecentLoginProvider, +} from "@/utils/auth/recentLoginProvider"; import { tokenStorage } from "@/utils/tokenStorage"; import { useOauth } from "./useOauth"; import { useAuthStore } from "@/store/useAuth.store"; @@ -30,6 +34,8 @@ export function useKakaoOauth({ if ("accessToken" in data) { // 기존 회원: accessToken 저장 후 메인으로 (refreshToken은 httpOnly 쿠키) + // 가입이 끝난 계정만 "최근 로그인"으로 기록 (신규 회원은 아직 계정이 없음) + setRecentLoginProvider("KAKAO"); tokenStorage.setTokens({ accessToken: data.accessToken, signupToken: null, @@ -38,6 +44,7 @@ export function useKakaoOauth({ onExistingMember(); } else { // 신규 회원: signupToken 저장 후 온보딩으로 + setPendingSignupProvider("KAKAO"); tokenStorage.setTokens({ accessToken: null, signupToken: data.signupToken, diff --git a/src/hooks/auth/onBoarding/useOnBoardingForm.ts b/src/hooks/auth/onBoarding/useOnBoardingForm.ts index 2dd5309c..9190a5b0 100644 --- a/src/hooks/auth/onBoarding/useOnBoardingForm.ts +++ b/src/hooks/auth/onBoarding/useOnBoardingForm.ts @@ -4,11 +4,14 @@ import type { TermsType } from "@/types/auth"; import { useDebouncedValue, useDebouncedTrue } from "@/hooks/common"; import { useShakeTrigger } from "@/hooks/common/useShakeTrigger"; import { + extractPhoneVerifyErrorCode, useConfirmPhoneVerification, useNicknameCheck, useRequestPhoneVerification, } from "@/hooks/member"; +import { PHONE_ALREADY_REGISTERED_CODE } from "@/constants/member/phone.constant"; import { tokenStorage } from "@/utils/tokenStorage"; +import { commitPendingSignupProvider } from "@/utils/auth/recentLoginProvider"; import { useSocialSignup } from "./useSignUp"; import { useAuthStore } from "@/store/useAuth.store"; @@ -43,6 +46,7 @@ export function useOnBoardingForm(options?: Options) { const [phoneVerifyMessage, setPhoneVerifyMessage] = useState(""); // 성공 문구 const [phoneVerifyError, setPhoneVerifyError] = useState(""); // 실패 문구 + const [isDuplicatePhone, setIsDuplicatePhone] = useState(false); // 이미 가입된 번호 안내 // 닉네임 인증 const debouncedNickname = useDebouncedValue(nickname, 400); @@ -134,7 +138,13 @@ export function useOnBoardingForm(options?: Options) { } // prettier-ignore }, - onError: (e) => console.error(e.message), + onError: (e) => { + if (extractPhoneVerifyErrorCode(e) === PHONE_ALREADY_REGISTERED_CODE) { + setIsDuplicatePhone(true); + return; + } + console.error(e.message); + }, }); const { mutate: confirmCode, isPending: isConfirmingCode } = @@ -222,6 +232,8 @@ export function useOnBoardingForm(options?: Options) { const { mutate: completeSignup, isPending: isCompleting } = useSocialSignup({ onSuccess: (res) => { + // 가입이 끝났으므로 임시 보관한 provider를 "최근 로그인"으로 확정 + commitPendingSignupProvider(); tokenStorage.setTokens({ accessToken: res.data.accessToken, signupToken: null, @@ -340,6 +352,10 @@ export function useOnBoardingForm(options?: Options) { phoneTextClass, lockPhoneForm, + // 이미 가입된 번호 안내 + isDuplicatePhone, + closeDuplicatePhoneDialog: () => setIsDuplicatePhone(false), + // shake keys nicknameShakeKey, phoneShakeKey, diff --git a/src/hooks/member/index.ts b/src/hooks/member/index.ts index ba13e2dc..34e9668f 100644 --- a/src/hooks/member/index.ts +++ b/src/hooks/member/index.ts @@ -1,5 +1,8 @@ export { useNicknameCheck } from "./useCheckNickName"; -export { useRequestPhoneVerification } from "./useRequestPhoneVerification"; +export { + useRequestPhoneVerification, + extractPhoneVerifyErrorCode, +} from "./useRequestPhoneVerification"; export { useConfirmPhoneVerification } from "./useConfirmPhoneVerification"; export { useMe, type MeResponse, ME_QUERY_KEY } from "./useMe"; export { useEditMe } from "./useEditMe"; diff --git a/src/hooks/member/useRequestPhoneVerification.ts b/src/hooks/member/useRequestPhoneVerification.ts index 320eb1dc..5810b19d 100644 --- a/src/hooks/member/useRequestPhoneVerification.ts +++ b/src/hooks/member/useRequestPhoneVerification.ts @@ -1,4 +1,5 @@ import { useMutation, type UseMutationOptions } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; import type { ApiResponse } from "@/types/common/apiResponse"; import { requestPhoneVerification } from "@/apis/member"; import type { @@ -18,3 +19,16 @@ export function useRequestPhoneVerification( ...options, }); } + +// axios 에러(HTTP 에러 상태) 또는 200 + success:false로 던져진 에러 모두에서 서버 ApiResponse.code(MEMBER_xxx) 추출 +export function extractPhoneVerifyErrorCode( + error: unknown, +): string | undefined { + if (isAxiosError<{ code?: string }>(error)) { + return error.response?.data?.code ?? error.code; + } + if (error instanceof Error && "code" in error) { + return (error as Error & { code?: string }).code; + } + return error instanceof Error ? error.message : undefined; +} diff --git a/src/layouts/RootLayout.tsx b/src/layouts/RootLayout.tsx index e8d688b7..6b952266 100644 --- a/src/layouts/RootLayout.tsx +++ b/src/layouts/RootLayout.tsx @@ -2,22 +2,27 @@ import { useEffect, useState } from "react"; import { useNavigate, Outlet } from "react-router"; import { App } from "@capacitor/app"; import { Capacitor } from "@capacitor/core"; -import GlobalLoginDialog from "@/components/common/GlobalLoginDialog"; import { DialogBox } from "@/components/common/DialogBox"; +import GlobalLoginDialog from "@/components/common/GlobalLoginDialog"; import { useNewPostState } from "@/store/useNewPostState.store"; +// 이탈 시 입력값이 날아가는 가입 단계. /auth/terms는 메인에서 push로 들어오는 +// 약관 열람 페이지라 제외(뒤로가기가 원래 화면으로 돌아가야 한다). +const SIGNUP_PATHS = ["/auth/agreement", "/auth/onboarding"]; + export default function RootLayout() { const navigate = useNavigate(); - const [exitDialogOpen, setExitDialogOpen] = useState(false); + const [isExitDialogOpen, setIsExitDialogOpen] = useState(false); useEffect(() => { if (!Capacitor.isNativePlatform()) return; // Promise 자체를 붙잡아, resolve 타이밍과 무관하게 클린업에서 리스너를 제거한다. const handlePromise = App.addListener("backButton", ({ canGoBack }) => { - // 종료 다이얼로그가 떠 있으면 뒤로가기로 닫기만 한다 - if (exitDialogOpen) { - setExitDialogOpen(false); + // 종료 다이얼로그가 떠 있으면 뒤로가기는 다이얼로그만 닫는다 + // (DialogBox의 useDismiss는 pointerdown/Escape만 처리 — 안드로이드 back은 안 잡힘) + if (isExitDialogOpen) { + setIsExitDialogOpen(false); return; } @@ -28,28 +33,40 @@ export default function RootLayout() { return; } + // 가입 플로우(약관/온보딩)에서 뒤로가기는 항상 로그인으로 되돌린다. + // canGoBack보다 먼저 판정해야 한다 — 로그인 다이얼로그로 진입하면 /auth/login이 + // push라 히스토리가 남고, canGoBack 분기에 먼저 걸려 입력 중이던 가입 정보가 날아간다. + // (이벤트 발생 시점에 읽어야 리스너 재등록 없이 최신 경로가 잡힌다) + if (SIGNUP_PATHS.includes(window.location.pathname)) { + navigate("/auth/login", { replace: true }); + return; + } + if (canGoBack) { navigate(-1); - } else { - setExitDialogOpen(true); + return; } + + // 뒤로 갈 히스토리가 없음(=뒤로가기 시 앱 종료): 즉시 종료 대신 확인 다이얼로그 + setIsExitDialogOpen(true); }); return () => { handlePromise.then((handle) => handle.remove()); }; - }, [navigate, exitDialogOpen]); + }, [navigate, isExitDialogOpen]); return (
setIsExitDialogOpen(false)} + confirmText="네" onConfirm={() => App.exitApp()} - onCancel={() => setExitDialogOpen(false)} /> {/* safe-area + 중앙 레이아웃(PC) + 패딩(모바일) */}
diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 2d124373..8f8c399d 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -1,4 +1,4 @@ -import { AppleButton, KakaoButton } from "@/components/auth"; +import { AppleButton, KakaoButton, RecentLoginDialog } from "@/components/auth"; import { CTA_Button, Press } from "@/components/common"; import { Link, useNavigate, useSearchParams } from "react-router"; import { useEffect, useMemo, useState } from "react"; @@ -9,6 +9,11 @@ import { Capacitor3KakaoLogin } from "capacitor3-kakao-login"; import { isNativeApp } from "@/utils/auth/envUtils"; import { isAndroidApp } from "@/utils/platform"; import { oauth } from "@/apis/auth"; +import { + getRecentLoginProvider, + setPendingSignupProvider, + setRecentLoginProvider, +} from "@/utils/auth/recentLoginProvider"; import { tokenStorage } from "@/utils/tokenStorage"; import { consumeRedirectAfterLogin } from "../demoDay/redirectAfterLogin"; import { SplashScreen } from "@capacitor/splash-screen"; // 앱 초기 스플래시 제어용 @@ -34,6 +39,9 @@ export function LoginPage() { const [authCheckStatus, setAuthCheckStatus] = useState("pending"); + // 최근에 로그인했던 SNS (버튼 위 안내용) + const [recentLoginProvider] = useState(() => getRecentLoginProvider()); + // 앱 실행 시 네이티브 스플래시 숨김 & 백그라운드 토큰 검사 useEffect(() => { // 앱인 경우 기본 제공되는 스플래시를 즉시 가리고 리액트 애니메이션 띄우기 @@ -156,12 +164,15 @@ export function LoginPage() { const data = response.data; if ("isNewMember" in data && data.isNewMember === true) { + setPendingSignupProvider("KAKAO"); await tokenStorage.setTokens({ accessToken: null, signupToken: data.signupToken, }); navigate("/auth/onboarding", { replace: true }); } else if ("member" in data) { + // 가입이 끝난 계정만 "최근 로그인"으로 기록 (신규 회원은 아직 계정이 없음) + setRecentLoginProvider("KAKAO"); await tokenStorage.setTokens({ accessToken: data.accessToken, signupToken: null, @@ -240,7 +251,7 @@ export function LoginPage() { > navigate("/mainpage", { replace: true })} color="orange" size="compact" /> @@ -259,9 +270,29 @@ export function LoginPage() { key={ui.footerKey} className={`mx-auto max-w-sm ${ui.footerAnim}`} > -
- {!isAndroidApp() && ( - +
+ {recentLoginProvider === "APPLE" && !isAndroidApp() && ( + + )} +
+ {!isAndroidApp() && ( + + )} + {recentLoginProvider === "KAKAO" && !isAndroidApp() && ( + + )} +
+ {recentLoginProvider === "KAKAO" && isAndroidApp() && ( + )}
diff --git a/src/pages/auth/OnBoarding.tsx b/src/pages/auth/OnBoarding.tsx index b5d861b0..f253ba2b 100644 --- a/src/pages/auth/OnBoarding.tsx +++ b/src/pages/auth/OnBoarding.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router"; import { formatMMSS } from "@/utils/time"; import { ActionButton, InputForm } from "@/components/auth"; import { Collapse, CTA_Button } from "@/components/common"; +import { DialogBox } from "@/components/common/DialogBox"; import { useOnBoardingForm } from "@/hooks/auth/onBoarding"; import type { TermsType } from "@/types/auth"; @@ -121,6 +122,15 @@ export function OnBoardingPage() { onClick={f.handleSubmit} /> + + navigate("/auth/login", { replace: true })} + onCancel={f.closeDuplicatePhoneDialog} + />
); } diff --git a/src/pages/auth/TermsAgreementPage.tsx b/src/pages/auth/TermsAgreementPage.tsx index 0154db43..5d7ca227 100644 --- a/src/pages/auth/TermsAgreementPage.tsx +++ b/src/pages/auth/TermsAgreementPage.tsx @@ -19,7 +19,10 @@ export function TermsAgreementPage() { const handleConfirm = () => { if (!requiredAllChecked) return; - navigate("/auth/onboarding", { state: { agreedTermTypes } }); + navigate("/auth/onboarding", { + state: { agreedTermTypes }, + replace: true, + }); }; return ( diff --git a/src/utils/auth/recentLoginProvider.ts b/src/utils/auth/recentLoginProvider.ts new file mode 100644 index 00000000..886f1aad --- /dev/null +++ b/src/utils/auth/recentLoginProvider.ts @@ -0,0 +1,45 @@ +import type { SocialProvider } from "@/types/auth"; + +const RECENT_LOGIN_PROVIDER_KEY = "finders:recentLoginProvider"; +const PENDING_SIGNUP_PROVIDER_KEY = "finders:pendingSignupProvider"; + +// 로그아웃과 무관하게 남아야 해서 useAuthStore(persist)와 분리된 별도 키로 관리 +export function setRecentLoginProvider(provider: SocialProvider) { + try { + localStorage.setItem(RECENT_LOGIN_PROVIDER_KEY, provider); + } catch (e) { + console.error("최근 로그인 정보 저장에 실패했습니다.", e); + } +} + +export function getRecentLoginProvider(): SocialProvider | null { + try { + const value = localStorage.getItem(RECENT_LOGIN_PROVIDER_KEY); + return value === "KAKAO" || value === "APPLE" ? value : null; + } catch { + return null; + } +} + +// 신규 회원은 가입이 끝나야 "최근 로그인"으로 기록되므로, 그때까지 provider를 임시 보관한다. +// (온보딩 시점엔 provider를 알 수 없고, 카카오 웹은 리다이렉트를 타므로 localStorage 사용) +export function setPendingSignupProvider(provider: SocialProvider) { + try { + localStorage.setItem(PENDING_SIGNUP_PROVIDER_KEY, provider); + } catch (e) { + console.error("가입 중 소셜 정보 저장에 실패했습니다.", e); + } +} + +// 가입 완료 시점에 임시 보관한 provider를 "최근 로그인"으로 확정한다. +export function commitPendingSignupProvider() { + try { + const value = localStorage.getItem(PENDING_SIGNUP_PROVIDER_KEY); + localStorage.removeItem(PENDING_SIGNUP_PROVIDER_KEY); + if (value === "KAKAO" || value === "APPLE") { + setRecentLoginProvider(value); + } + } catch (e) { + console.error("최근 로그인 정보 저장에 실패했습니다.", e); + } +}