Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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 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 @@ -25,6 +26,8 @@ export function useAppleLogin({
onSuccess: (res) => {
const data = res.data;

setRecentLoginProvider("APPLE");

if ("accessToken" in data) {
tokenStorage.setTokens({
accessToken: data.accessToken,
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 @@ -28,6 +29,8 @@ export function useKakaoOauth({
onSuccess: (res) => {
const data = res.data;

setRecentLoginProvider("KAKAO");
Comment thread
jeonbinggu marked this conversation as resolved.
Outdated

if ("accessToken" in data) {
// 기존 회원: accessToken 저장 후 메인으로 (refreshToken은 httpOnly 쿠키)
tokenStorage.setTokens({
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;
}
16 changes: 14 additions & 2 deletions src/layouts/RootLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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;
Expand All @@ -23,7 +25,8 @@ export default function RootLayout() {
if (canGoBack) {
navigate(-1);
} else {
App.exitApp();
// 뒤로 갈 히스토리가 없음(=뒤로가기 시 앱 종료): 즉시 종료 대신 확인 다이얼로그
setIsExitDialogOpen(true);
Comment thread
jeonbinggu marked this conversation as resolved.
Outdated
}
});

Expand All @@ -35,6 +38,15 @@ export default function RootLayout() {
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 @@ -155,6 +162,8 @@ export function LoginPage() {

const data = response.data;

setRecentLoginProvider("KAKAO");

if ("isNewMember" in data && data.isNewMember === true) {
await tokenStorage.setTokens({
accessToken: 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.

기획에 의해 의도한 것임.

)}
</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