Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion android/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ keystore.properties
.cxx/

# Google Services (e.g. APIs or Firebase)
# google-services.json
google-services.json

# Freeline
freeline.py
Expand Down
2 changes: 1 addition & 1 deletion src/apis/member/requestPhoneVerification.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/assets/icon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions src/assets/icon/polygon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions src/components/auth/RecentLoginDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { PolygonIcon } from "@/assets/icon";

interface RecentLoginDialogProps {
className?: string;
}

export function RecentLoginDialog({ className = "" }: RecentLoginDialogProps) {
return (
<div className={`flex flex-col items-center self-center ${className}`}>
<div className="bg-neutral-0 rounded-lg px-2 py-1.25 text-xs font-semibold text-orange-500">
최근에 로그인 했어요
</div>
<PolygonIcon className="-mt-0.5 h-2.5 w-2.5" />
</div>
);
}
1 change: 1 addition & 0 deletions src/components/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { ActionButton } from "./ActionButton";
export { TermsAgreementItem } from "./TermsAgreementItem";
export { TermsContent } from "./TermsContent";
export { TermsAccordionRow } from "./TermsAccordionRow";
export { RecentLoginDialog } from "./RecentLoginDialog";
2 changes: 2 additions & 0 deletions src/constants/member/phone.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// 서버 ErrorCode: 인증번호 발송 시 이미 가입된 전화번호
export const PHONE_ALREADY_REGISTERED_CODE = "MEMBER_412";
3 changes: 3 additions & 0 deletions src/hooks/auth/login/useAppleLogin.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -26,6 +27,8 @@ export function useAppleLogin({
const data = res.data;

if ("accessToken" in data) {
// 가입이 끝난 계정만 "최근 로그인"으로 기록 (신규 회원은 아직 계정이 없음)
setRecentLoginProvider("APPLE");
tokenStorage.setTokens({
accessToken: data.accessToken,
signupToken: null,
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/auth/login/useKakaoOAuth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -30,6 +31,8 @@ export function useKakaoOauth({

if ("accessToken" in data) {
// 기존 회원: accessToken 저장 후 메인으로 (refreshToken은 httpOnly 쿠키)
// 가입이 끝난 계정만 "최근 로그인"으로 기록 (신규 회원은 아직 계정이 없음)
setRecentLoginProvider("KAKAO");
tokenStorage.setTokens({
accessToken: data.accessToken,
signupToken: null,
Expand Down
15 changes: 14 additions & 1 deletion src/hooks/auth/onBoarding/useOnBoardingForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -43,6 +45,7 @@ export function useOnBoardingForm(options?: Options) {

const [phoneVerifyMessage, setPhoneVerifyMessage] = useState<string>(""); // 성공 문구
const [phoneVerifyError, setPhoneVerifyError] = useState<string>(""); // 실패 문구
const [isDuplicatePhone, setIsDuplicatePhone] = useState(false); // 이미 가입된 번호 안내

// 닉네임 인증
const debouncedNickname = useDebouncedValue(nickname, 400);
Expand Down Expand Up @@ -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 } =
Expand Down Expand Up @@ -340,6 +349,10 @@ export function useOnBoardingForm(options?: Options) {
phoneTextClass,
lockPhoneForm,

// 이미 가입된 번호 안내
isDuplicatePhone,
closeDuplicatePhoneDialog: () => setIsDuplicatePhone(false),

// shake keys
nicknameShakeKey,
phoneShakeKey,
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/member/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
14 changes: 14 additions & 0 deletions src/hooks/member/useRequestPhoneVerification.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}
37 changes: 33 additions & 4 deletions src/layouts/RootLayout.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 (
<div className="min-h-dvh w-full bg-neutral-900 text-neutral-100">
<GlobalLoginDialog />
<DialogBox
isOpen={isExitDialogOpen}
title="파인더스 앱 닫기"
description="파인더스를 종료하시겠어요?"
cancelText="아니오"
onCancel={() => setIsExitDialogOpen(false)}
confirmText="네"
onConfirm={() => App.exitApp()}
/>
{/* safe-area + 중앙 레이아웃(PC) + 패딩(모바일) */}
<div className="mx-auto flex min-h-dvh w-full max-w-120 flex-col px-4 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] sm:px-6 lg:px-8">
{/* Rootlayout으로 감싸진 모든 컴포넌트 렌더링*/}
Expand Down
39 changes: 34 additions & 5 deletions src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"; // 앱 초기 스플래시 제어용
Expand All @@ -34,6 +38,9 @@ export function LoginPage() {
const [authCheckStatus, setAuthCheckStatus] =
useState<AuthCheckStatus>("pending");

// 최근에 로그인했던 SNS (버튼 위 안내용)
const [recentLoginProvider] = useState(() => getRecentLoginProvider());

// 앱 실행 시 네이티브 스플래시 숨김 & 백그라운드 토큰 검사
useEffect(() => {
// 앱인 경우 기본 제공되는 스플래시를 즉시 가리고 리액트 애니메이션 띄우기
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -240,7 +249,7 @@ export function LoginPage() {
>
<CTA_Button
text="홈으로"
link="/mainpage"
onClick={() => navigate("/mainpage", { replace: true })}
color="orange"
size="compact"
/>
Expand All @@ -259,9 +268,29 @@ export function LoginPage() {
key={ui.footerKey}
className={`mx-auto max-w-sm ${ui.footerAnim}`}
>
<div className="flex flex-col gap-2">
{!isAndroidApp() && (
<AppleButton onClick={apple.login} disabled={apple.isPending} />
<div className="flex flex-col gap-3">
{recentLoginProvider === "APPLE" && !isAndroidApp() && (
<RecentLoginDialog />
)}
<div
className={
recentLoginProvider === "KAKAO" && !isAndroidApp()
? "relative"
: undefined
}
>
{!isAndroidApp() && (
<AppleButton
onClick={apple.login}
disabled={apple.isPending}
/>
)}
{recentLoginProvider === "KAKAO" && !isAndroidApp() && (
<RecentLoginDialog className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/4" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Major] pointer-events-none이 없어 이 말풍선이 Apple 버튼의 탭 영역을 가립니다.

말풍선은 AppleButton과 같은 relative 컨테이너 안에 position: absolute로, DOM상 버튼 에 렌더됩니다. 버튼(Press<button>)은 static이라 페인팅 순서상 말풍선이 위에 오고, 히트 테스트도 말풍선이 먼저 받습니다.

겹침 계산: 말풍선 높이 34px(text-xs 16 + py-1.25 10 + Polygon h-2.5 10 − -mt-0.5 2), 컨테이너 높이 = AppleButtonh-[3.125rem] 50px. bottom-0 → top 16px, 여기에 translate-y-1/4(자기 높이의 25% = 8.5px) → top 24.5px. 즉 **y 24.5~50px, 버튼 높이의 51%**가 덮입니다. 가로로는 left-1/2 -translate-x-1/2로 중앙 약 130px — "Apple로 계속하기" 라벨이 있는 지점입니다.

재현: iOS 앱 또는 모바일 웹에서 finders:recentLoginProvider"KAKAO"인 상태 → 로그인 화면 → Apple 버튼 중앙 아래쪽 탭 → SignInWithApple.authorize()가 호출되지 않습니다. 버튼 좌우 끝이나 위쪽만 반응합니다.

정적 외형은 그대로 두고 히트 테스트만 통과시키면 됩니다.

Suggested change
<RecentLoginDialog className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/4" />
<RecentLoginDialog className="pointer-events-none absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/4" />

Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

기획에 의해 의도한 것임.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

그 버튼이 가려지는 건 그럴 수 있는데 터치가 막히는 것도 의도된건가요?

@jeonbinggu jeonbinggu Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

넵 어차피 카카오로 회원가입한 사용자는 애플로 시작하기를 안건들게 하는게 기획의 의도 같아서요..! 버튼 클릭이 안먹는거면 일단 다시 한 번 확인해 보겠습니다!

)}
</div>
{recentLoginProvider === "KAKAO" && isAndroidApp() && (
<RecentLoginDialog />
)}
<KakaoButton onClick={handleKakaoLogin} />
</div>
Expand Down
10 changes: 10 additions & 0 deletions src/pages/auth/OnBoarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -121,6 +122,15 @@ export function OnBoardingPage() {
onClick={f.handleSubmit}
/>
</footer>

<DialogBox
isOpen={f.isDuplicatePhone}
title="이미 가입한 회원입니다"
description="이전에 가입한 계정으로 로그인해주세요."
confirmText="확인"
onConfirm={() => navigate("/auth/login", { replace: true })}
onCancel={f.closeDuplicatePhoneDialog}
/>
</div>
);
}
5 changes: 4 additions & 1 deletion src/pages/auth/TermsAgreementPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export function TermsAgreementPage() {

const handleConfirm = () => {
if (!requiredAllChecked) return;
navigate("/auth/onboarding", { state: { agreedTermTypes } });
navigate("/auth/onboarding", {
state: { agreedTermTypes },
replace: true,
Comment thread
jeonbinggu marked this conversation as resolved.
});
};

return (
Expand Down
2 changes: 1 addition & 1 deletion src/router/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ const router = createBrowserRouter([
{
Component: RootLayout,
children: [
{ index: true, element: <Navigate to="/auth/login" /> },
{ index: true, element: <Navigate to="/auth/login" replace /> },

// auth prefix를 한 번만
{ path: "auth", children: authRoutes },
Expand Down
21 changes: 21 additions & 0 deletions src/utils/auth/recentLoginProvider.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading