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..cb0e8dfa 100644 --- a/src/hooks/auth/login/useAppleLogin.ts +++ b/src/hooks/auth/login/useAppleLogin.ts @@ -1,5 +1,6 @@ import { useState } from "react"; import { loginWithApple } from "@/utils/auth/appleSdk"; +import { setRecentLoginProvider } from "@/utils/auth/recentLoginProvider"; import { tokenStorage } from "@/utils/tokenStorage"; import { useOauth } from "./useOauth"; import { useAuthStore } from "@/store/useAuth.store"; @@ -26,6 +27,8 @@ export function useAppleLogin({ const data = res.data; if ("accessToken" in data) { + // 가입이 끝난 계정만 "최근 로그인"으로 기록 (신규 회원은 아직 계정이 없음) + setRecentLoginProvider("APPLE"); tokenStorage.setTokens({ accessToken: data.accessToken, signupToken: null, diff --git a/src/hooks/auth/login/useKakaoOAuth.ts b/src/hooks/auth/login/useKakaoOAuth.ts index 1cd93c28..442f67c0 100644 --- a/src/hooks/auth/login/useKakaoOAuth.ts +++ b/src/hooks/auth/login/useKakaoOAuth.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import { useSearchParams } from "react-router"; import { consumeAndValidateKakaoState } from "@/utils/auth/kakaoOauth"; +import { setRecentLoginProvider } from "@/utils/auth/recentLoginProvider"; import { tokenStorage } from "@/utils/tokenStorage"; import { useOauth } from "./useOauth"; import { useAuthStore } from "@/store/useAuth.store"; @@ -30,6 +31,8 @@ export function useKakaoOauth({ if ("accessToken" in data) { // 기존 회원: accessToken 저장 후 메인으로 (refreshToken은 httpOnly 쿠키) + // 가입이 끝난 계정만 "최근 로그인"으로 기록 (신규 회원은 아직 계정이 없음) + setRecentLoginProvider("KAKAO"); tokenStorage.setTokens({ accessToken: data.accessToken, signupToken: null, diff --git a/src/hooks/auth/onBoarding/useOnBoardingForm.ts b/src/hooks/auth/onBoarding/useOnBoardingForm.ts index 2dd5309c..7577fed8 100644 --- a/src/hooks/auth/onBoarding/useOnBoardingForm.ts +++ b/src/hooks/auth/onBoarding/useOnBoardingForm.ts @@ -4,10 +4,12 @@ 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 { useSocialSignup } from "./useSignUp"; import { useAuthStore } from "@/store/useAuth.store"; @@ -43,6 +45,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 +137,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 } = @@ -340,6 +349,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 c3b3bb47..8fc989ac 100644 --- a/src/layouts/RootLayout.tsx +++ b/src/layouts/RootLayout.tsx @@ -1,18 +1,27 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useNavigate, Outlet } from "react-router"; import { App } from "@capacitor/app"; import { Capacitor } from "@capacitor/core"; +import { DialogBox } from "@/components/common/DialogBox"; import GlobalLoginDialog from "@/components/common/GlobalLoginDialog"; import { useNewPostState } from "@/store/useNewPostState.store"; export default function RootLayout() { const navigate = useNavigate(); + const [isExitDialogOpen, setIsExitDialogOpen] = useState(false); useEffect(() => { if (!Capacitor.isNativePlatform()) return; // Promise 자체를 붙잡아, resolve 타이밍과 무관하게 클린업에서 리스너를 제거한다. const handlePromise = App.addListener("backButton", ({ canGoBack }) => { + // 종료 다이얼로그가 떠 있으면 뒤로가기는 다이얼로그만 닫는다 + // (DialogBox의 useDismiss는 pointerdown/Escape만 처리 — 안드로이드 back은 안 잡힘) + if (isExitDialogOpen) { + setIsExitDialogOpen(false); + return; + } + // 게시글 등록 직후엔 하드웨어 뒤로가기도 이전 작성 단계가 아니라 피드로 이동 if (useNewPostState.getState().isNewPost) { useNewPostState.getState().setIsNewPost(false); @@ -22,19 +31,39 @@ export default function RootLayout() { if (canGoBack) { navigate(-1); - } else { - App.exitApp(); + return; } + + // 가입 플로우(약관/온보딩)는 전 구간 replace라 히스토리가 비어 있다. + // 여기서 앱을 닫으면 입력 중이던 가입 정보가 날아가므로 로그인으로 되돌린다. + // (이벤트 발생 시점에 읽어야 리스너 재등록 없이 최신 경로가 잡힌다) + const { pathname } = window.location; + if (pathname.startsWith("/auth/") && pathname !== "/auth/login") { + navigate("/auth/login", { replace: true }); + return; + } + + // 뒤로 갈 히스토리가 없음(=뒤로가기 시 앱 종료): 즉시 종료 대신 확인 다이얼로그 + setIsExitDialogOpen(true); }); return () => { handlePromise.then((handle) => handle.remove()); }; - }, [navigate]); + }, [navigate, isExitDialogOpen]); return (
+ setIsExitDialogOpen(false)} + confirmText="네" + onConfirm={() => App.exitApp()} + /> {/* safe-area + 중앙 레이아웃(PC) + 패딩(모바일) */}
{/* Rootlayout으로 감싸진 모든 컴포넌트 렌더링*/} diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 2d124373..aeb28a67 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,10 @@ import { Capacitor3KakaoLogin } from "capacitor3-kakao-login"; import { isNativeApp } from "@/utils/auth/envUtils"; import { isAndroidApp } from "@/utils/platform"; import { oauth } from "@/apis/auth"; +import { + getRecentLoginProvider, + setRecentLoginProvider, +} from "@/utils/auth/recentLoginProvider"; import { tokenStorage } from "@/utils/tokenStorage"; import { consumeRedirectAfterLogin } from "../demoDay/redirectAfterLogin"; import { SplashScreen } from "@capacitor/splash-screen"; // 앱 초기 스플래시 제어용 @@ -34,6 +38,9 @@ export function LoginPage() { const [authCheckStatus, setAuthCheckStatus] = useState("pending"); + // 최근에 로그인했던 SNS (버튼 위 안내용) + const [recentLoginProvider] = useState(() => getRecentLoginProvider()); + // 앱 실행 시 네이티브 스플래시 숨김 & 백그라운드 토큰 검사 useEffect(() => { // 앱인 경우 기본 제공되는 스플래시를 즉시 가리고 리액트 애니메이션 띄우기 @@ -162,6 +169,8 @@ export function LoginPage() { }); navigate("/auth/onboarding", { replace: true }); } else if ("member" in data) { + // 가입이 끝난 계정만 "최근 로그인"으로 기록 (신규 회원은 아직 계정이 없음) + setRecentLoginProvider("KAKAO"); await tokenStorage.setTokens({ accessToken: data.accessToken, signupToken: null, @@ -240,7 +249,7 @@ export function LoginPage() { > navigate("/mainpage", { replace: true })} color="orange" size="compact" /> @@ -259,9 +268,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/router/Router.tsx b/src/router/Router.tsx index 68bcbd99..15d41fb6 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -267,7 +267,7 @@ const router = createBrowserRouter([ { Component: RootLayout, children: [ - { index: true, element: }, + { index: true, element: }, // auth prefix를 한 번만 { path: "auth", children: authRoutes }, diff --git a/src/utils/auth/recentLoginProvider.ts b/src/utils/auth/recentLoginProvider.ts new file mode 100644 index 00000000..26c3a8b0 --- /dev/null +++ b/src/utils/auth/recentLoginProvider.ts @@ -0,0 +1,21 @@ +import type { SocialProvider } from "@/types/auth"; + +const RECENT_LOGIN_PROVIDER_KEY = "finders:recentLoginProvider"; + +// 로그아웃과 무관하게 남아야 해서 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; + } +}