Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e3d39e0
feat: 진단 폼에 위험도 분석 섹션으로 스크롤 기능 추가 및 Props 타입 정의
Dobbymin Aug 22, 2025
3ddb954
fix: DETAIL_ADDRESS의 문장 끝 마침표 제거
Dobbymin Aug 22, 2025
51c1705
feat: InputSection 컴포넌트에 scrollToRiskAnalysis prop 추가 및 타입 정의
Dobbymin Aug 22, 2025
7cfa50b
feat: RiskAnalysisSummarySection 컴포넌트를 forwardRef로 수정하여 ref 전달 기능 추가
Dobbymin Aug 22, 2025
8c8f326
feat: MainPage 컴포넌트에 위험도 분석 섹션으로 스크롤 기능 추가 및 ref 전달 구현
Dobbymin Aug 22, 2025
286a15b
feat: Vite 설정에 API 프록시 추가하여 개발 환경에서 API 요청 처리
Dobbymin Aug 22, 2025
45add72
feat: GET_TICKET_API_PATH를 함수로 변경하여 동적 경로 처리 구현
Dobbymin Aug 22, 2025
08ecb15
feat: refresh-token.api를 추가하여 인증 API 확장
Dobbymin Aug 22, 2025
dfdf6d4
feat: refresh-token.api.ts 파일 추가하여 인증 토큰 갱신 API 구현
Dobbymin Aug 22, 2025
4b7fe0c
feat: getAuthTicket.ts 파일 삭제 및 useGetAuthTicket로 변경하여 인증 티켓 훅 통합
Dobbymin Aug 22, 2025
075fc19
fix: auth-storage.ts에서 JSON 파싱 및 문자열 변환 로직 수정
Dobbymin Aug 22, 2025
9f2f7c7
feat: fetch-instance.ts에 응답 인터셉터 추가 및 재인증 로직 구현
Dobbymin Aug 22, 2025
d879a9b
feat: useGetAuthTicket 훅 추가하여 인증 티켓을 가져오는 기능 구현
Dobbymin Aug 22, 2025
649615e
feat: size-price.api.ts 파일 추가하여 사이즈 가격 데이터 API 구현
Dobbymin Aug 22, 2025
de4628e
fix: DIAGNOSIS_API_PATH 경로 수정하여 API 경로 일관성 유지
Dobbymin Aug 22, 2025
d927d1c
feat: 카카오 로그인 버튼 기능 수정 및 API 경로 통합
Dobbymin Aug 22, 2025
170e603
feat: useGetRefreshToken 훅 추가하여 인증 토큰 갱신 기능 구현
Dobbymin Aug 22, 2025
f4671cf
feat: useGetTicket 훅 추가하여 카카오 로그인 시 인증 티켓을 가져오는 기능 구현
Dobbymin Aug 22, 2025
07b2541
feat: OAuthRedirectPage 컴포넌트 수정하여 인증 티켓 및 리프레시 토큰 처리 로직 개선
Dobbymin Aug 22, 2025
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
5 changes: 3 additions & 2 deletions src/entities/auth/apis/get-ticket.api.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { fetchInstance } from '@/shared';

export const GET_TICKET_API_PATH = '/api/auth/ticket';
export const GET_TICKET_API_PATH = () => '/auth/ticket';

interface GetTicketApiResponse {
refreshToken: string;
}

export const getTicketApi = async (ticket: string): Promise<GetTicketApiResponse> => {
const response = await fetchInstance.get<GetTicketApiResponse>(GET_TICKET_API_PATH, {
const response = await fetchInstance.get<GetTicketApiResponse>(GET_TICKET_API_PATH(), {
params: {
ticket,
},
});

return response.data;
};
1 change: 1 addition & 0 deletions src/entities/auth/apis/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './get-ticket.api';
export * from './refresh-token.api';
21 changes: 21 additions & 0 deletions src/entities/auth/apis/refresh-token.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { fetchInstance } from '@/shared';

export const REFRESH_TOKEN_API_PATH = '/auth/refresh';

interface RefreshTokenApiRequest {
refreshToken: string;
}

interface RefreshTokenApiResponse {
accessToken: string;
}

export const refreshTokenApi = async ({
refreshToken,
}: RefreshTokenApiRequest): Promise<RefreshTokenApiResponse> => {
const response = await fetchInstance.post<RefreshTokenApiResponse>(REFRESH_TOKEN_API_PATH, {
refreshToken,
});

return response.data;
};
14 changes: 0 additions & 14 deletions src/entities/auth/hooks/getAuthTicket.ts

This file was deleted.

3 changes: 2 additions & 1 deletion src/entities/auth/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './getAuthTicket';
export * from './useGetAuthTicket';
export * from './useKakaoLogin';
export * from './useGetRefreshToken';
13 changes: 13 additions & 0 deletions src/entities/auth/hooks/useGetAuthTicket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';

import { GET_TICKET_API_PATH, getTicketApi } from '../apis';

export const GetAuthTicketQueryKey = [GET_TICKET_API_PATH()];

export const useGetAuthTicket = (ticket: string) => {
return useQuery({
queryKey: [GetAuthTicketQueryKey, ticket],
queryFn: () => getTicketApi(ticket),
enabled: !!ticket && ticket.trim().length > 0,
});
};
15 changes: 15 additions & 0 deletions src/entities/auth/hooks/useGetRefreshToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';

import { authStorage } from '@/shared';

import { REFRESH_TOKEN_API_PATH, refreshTokenApi } from '../apis';

export const GetRefreshTokenQueryKey = [REFRESH_TOKEN_API_PATH];

export const useGetRefreshToken = () => {
return useQuery({
queryKey: [GetRefreshTokenQueryKey],
queryFn: () => refreshTokenApi({ refreshToken: authStorage.refreshToken.get() }),
enabled: !!authStorage.refreshToken.get(),
});
};
8 changes: 7 additions & 1 deletion src/entities/auth/hooks/useKakaoLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export const useKakaoCallback = (ticket: string) => {
queryKey: KakaoCallbackQueryKey.callback(ticket),
queryFn: () => getTicketApi(ticket),
retry: 0,
staleTime: Infinity,
enabled: !!ticket, // ticket이 있을 때만 API 호출
});
};

export const useGetTicket = (ticket: string) => {
return useMutation({
mutationFn: () => getTicketApi(ticket),
});
};
Empty file.
16 changes: 16 additions & 0 deletions src/entities/chart/apis/size-price.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { fetchInstance } from '@/shared';

export const SIZE_PRICE_API_PATH = '/graph/sizePriceIndex';

interface SizePriceApiResponse {
month: string;
sizeband: string;
baseindex: number;
changerate: number;
}

export const sizePriceApi = async (): Promise<SizePriceApiResponse> => {
const response = await fetchInstance.get<SizePriceApiResponse>(SIZE_PRICE_API_PATH);
Comment on lines +12 to +13
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

sizePriceApi 함수의 반환 타입이 단일 SizePriceApiResponse 객체에 대한 Promise로 지정되어 있습니다. 하지만 그래프에 사용되는 sizePriceIndex와 같은 API 엔드포인트는 일반적으로 데이터 포인트의 배열을 반환합니다. 만약 API가 배열을 반환한다면, 타입 불일치로 인해 런타임 오류가 발생할 수 있습니다. 실제 API 응답을 확인하고, 필요한 경우 반환 타입을 Promise<SizePriceApiResponse[]>로 수정해주세요.

Suggested change
export const sizePriceApi = async (): Promise<SizePriceApiResponse> => {
const response = await fetchInstance.get<SizePriceApiResponse>(SIZE_PRICE_API_PATH);
export const sizePriceApi = async (): Promise<SizePriceApiResponse[]> => {
const response = await fetchInstance.get<SizePriceApiResponse[]>(SIZE_PRICE_API_PATH);


return response.data;
};
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { useKakaoLogin } from '@/entities';
import { Button } from '@/shared';
import { BASE_URL, Button } from '@/shared';

import KakaoSymbol from '../../../_assets/kakao-symbol.webp';

export const KakaoLoginButton = () => {
const { mutate: startKakaoLogin, isPending } = useKakaoLogin();
const startKakaoLogin = () => {
window.location.href = `${BASE_URL}/oauth2/authorization/kakao`;
};

return (
<Button
onClick={() => startKakaoLogin()}
className='flex h-11 w-90 items-center gap-3 rounded-lg bg-kakao text-black hover:bg-kakao/80 active:bg-kakao/80'
>
<img src={KakaoSymbol} className='size-4' width={16} height={16} alt='kakao' />
<span className='text-sm font-semibold'>
{isPending ? '이동중' : '카카오 계정으로 계속하기'}
</span>
<span className='text-sm font-semibold'>카카오 계정으로 계속하기</span>
</Button>
);
};
2 changes: 1 addition & 1 deletion src/features/main/apis/diagnosis.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { fetchInstance } from '@/shared';

import type { HouseType } from '../types';

export const DIAGNOSIS_API_PATH = '/api/diagnosis';
export const DIAGNOSIS_API_PATH = '/diagnosis';

interface DiagnosisApiRequest {
address: string;
Expand Down
21 changes: 20 additions & 1 deletion src/features/main/components/features/form/DiagnosticForm.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type RefObject } from 'react';
import { useForm } from 'react-hook-form';

import { zodResolver } from '@hookform/resolvers/zod';
Expand All @@ -17,7 +18,11 @@ import {
RentTypeField,
} from '../../common';

export const DiagnosticForm = () => {
type Props = {
scrollToRiskAnalysis: RefObject<HTMLDivElement>;
};

export const DiagnosticForm = ({ scrollToRiskAnalysis }: Props) => {
const { setDiagnosisData } = useHouseData();

const form = useForm<SearchAddressType>({
Expand All @@ -40,6 +45,20 @@ export const DiagnosticForm = () => {
}),
onSuccess: (data) => {
setDiagnosisData(data);

// 진단 완료 후 위험도 분석 섹션으로 스크롤
setTimeout(() => {
if (scrollToRiskAnalysis.current) {
const element = scrollToRiskAnalysis.current;
const elementTop = element.offsetTop;
const offset = 80;

window.scrollTo({
top: elementTop - offset,
behavior: 'smooth',
});
}
}, 100);
Comment on lines +50 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

setTimeout을 사용하여 스크롤을 지연시키는 방식은 렌더링 완료를 고정된 시간에 의존하기 때문에 불안정할 수 있습니다. 타임아웃 없이도 스크롤이 올바르게 작동해야 합니다. 만약 레이아웃 계산을 기다리는 등 특별한 이유가 있다면, 브라우저의 렌더링 주기에 더 잘 맞는 requestAnimationFrame을 사용하거나, 주석으로 타임아웃의 필요성을 설명하는 것을 고려해보세요.

      // 진단 완료 후 위험도 분석 섹션으로 스크롤
      if (scrollToRiskAnalysis.current) {
        const element = scrollToRiskAnalysis.current;
        const elementTop = element.offsetTop;
        const offset = 80;

        window.scrollTo({
          top: elementTop - offset,
          behavior: 'smooth',
        });
      }

},
});

Expand Down
2 changes: 1 addition & 1 deletion src/features/main/constants/form-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const FORM_FIELDS = {
export const PLACEHOLDER_TEXTS = {
ADDRESS: '주소를 알려주세요',
HOUSE_TYPE: '주택유형을 알려주세요',
DETAIL_ADDRESS: '상세주소를 알려주세요.',
DETAIL_ADDRESS: '상세주소를 알려주세요',
DEPOSIT: '전세금액',
} as const;

Expand Down
10 changes: 8 additions & 2 deletions src/features/main/ui/InputSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { type RefObject } from 'react';

import { DiagnosticForm } from '../components';

export const InputSection = () => {
type Props = {
scrollToRiskAnalysis: RefObject<HTMLDivElement>;
};

export const InputSection = ({ scrollToRiskAnalysis }: Props) => {
return (
<div className='flex w-1/2 flex-col items-center justify-center gap-6 p-8'>
<div className='flex w-full max-w-md flex-col gap-4'>
Expand All @@ -11,7 +17,7 @@ export const InputSection = () => {
</span>
<span className='text-4xl font-bold'>안심할 수 있는지 확인해봐요!</span>
</div>
<DiagnosticForm />
<DiagnosticForm scrollToRiskAnalysis={scrollToRiskAnalysis} />
</div>
</div>
);
Expand Down
13 changes: 10 additions & 3 deletions src/features/main/ui/RiskAnalysisSummarySection.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { forwardRef } from 'react';

import { Spinner } from '@/shared';

import { RiskChartBox, RiskFactorsBox } from '../components';
import { DEFAULT_RISK_ANALYSIS_DATA } from '../mock/risk-analysis.mock';
import { useHouseData } from '../store';

export const RiskAnalysisSummarySection = () => {
export const RiskAnalysisSummarySection = forwardRef<HTMLDivElement>((_, ref) => {
const { diagnosisData, isLoading, error } = useHouseData();

if (isLoading) return <Spinner />;
Expand All @@ -20,7 +22,10 @@ export const RiskAnalysisSummarySection = () => {
}

return (
<div className='mx-auto mt-10 flex w-full max-w-7xl rounded-lg bg-white shadow-[0px_4px_30px_0px_#0000001A]'>
<div
ref={ref}
className='mx-auto mt-10 flex w-full max-w-7xl rounded-lg bg-white shadow-[0px_4px_30px_0px_#0000001A]'
>
{/* 메인 위험도 분석 섹션 */}
<div className='flex w-full justify-between px-10 pt-5'>
{/* 왼쪽: 위험도 게이지 */}
Expand All @@ -30,4 +35,6 @@ export const RiskAnalysisSummarySection = () => {
</div>
</div>
);
};
});

RiskAnalysisSummarySection.displayName = 'RiskAnalysisSummarySection';
8 changes: 6 additions & 2 deletions src/pages/main/MainPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useRef } from 'react';

import {
AlternativeSection,
ChartSection,
Expand All @@ -9,13 +11,15 @@ import {
} from '@/features';

export default function MainPage() {
const riskAnalysisRef = useRef<HTMLDivElement>(null);

return (
<div className='flex min-h-screen w-full flex-col'>
<div className='flex w-full gap-4'>
<InputSection />
<InputSection scrollToRiskAnalysis={riskAnalysisRef as React.RefObject<HTMLDivElement>} />
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

여기서 as React.RefObject<HTMLDivElement> 타입 단언은 불필요합니다. riskAnalysisRef는 이미 prop이 요구하는 타입인 React.RefObject<HTMLDivElement> 타입이므로, 이 단언은 안전하게 제거할 수 있습니다.

Suggested change
<InputSection scrollToRiskAnalysis={riskAnalysisRef as React.RefObject<HTMLDivElement>} />
<InputSection scrollToRiskAnalysis={riskAnalysisRef} />

<MapSection />
</div>
<RiskAnalysisSummarySection />
<RiskAnalysisSummarySection ref={riskAnalysisRef} />
<ChartSection />
<LandlordReliabilitySection />
<LandlordPropertySection />
Expand Down
65 changes: 43 additions & 22 deletions src/pages/oauth/OAuthRedirectPage.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,54 @@
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';

import { toast } from 'sonner';

import { useKakaoCallback } from '@/entities';
import { ROUTER_PATH, authStorage } from '@/shared';
import { useGetAuthTicket, useGetRefreshToken } from '@/entities';
import { ROUTER_PATH, Spinner, authStorage } from '@/shared';

export default function OAuthRedirectPage() {
const [searchParams] = useSearchParams();

const location = useLocation();
const navigate = useNavigate();

const ticket = searchParams.get('ticket');
const ticket = new URLSearchParams(location.search).get('ticket');

const { data, isSuccess, isError } = useKakaoCallback(ticket ?? '');
const { data, isLoading } = useGetAuthTicket(ticket ?? '');

useEffect(() => {
if (isSuccess && data) {
authStorage.refreshToken.set(data.refreshToken);

navigate(ROUTER_PATH.ROOT);
if (data) {
const refreshToken = data.refreshToken;
authStorage.refreshToken.set(refreshToken);
navigate(ROUTER_PATH.ROOT, { replace: true });
}

if (isError) {
// 실패 시, 에러 처리 후 로그인 페이지로 이동
toast.error('카카오 로그인에 실패했습니다.');
navigate(ROUTER_PATH.LOGIN);
}
}, [isSuccess, isError, data, navigate]);

return <div>로그인 중입니다...</div>;
}, [data, navigate]);
Comment on lines 15 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

현재 구현은 refreshToken을 받자마자 메인 페이지로 이동시켜 레이스 컨디션이 발생할 수 있습니다. accessToken을 받기 전에 페이지가 전환되어 accessToken이 제대로 저장되지 않을 수 있습니다.

이 문제를 해결하려면 navigate 호출을 분리해야 합니다. 현재 useEffect에서는 refreshToken만 저장하고, accessToken을 성공적으로 가져온 후 navigate를 호출하는 새로운 useEffect를 추가하는 것을 권장합니다.

예시:

// 기존 useEffect 수정
useEffect(() => {
  if (data) {
    authStorage.refreshToken.set(data.refreshToken);
  }
}, [data]);

// refreshTokenData를 받아오는 로직은 그대로 둡니다.
const { data: refreshTokenData, isLoading: isRefreshTokenLoading } = useGetRefreshToken();

// 새로운 useEffect 추가
useEffect(() => {
  if (refreshTokenData) {
    authStorage.accessToken.set(refreshTokenData.accessToken);
    navigate(ROUTER_PATH.ROOT, { replace: true });
  }
}, [refreshTokenData, navigate]);

위 코드처럼 로직을 분리하면 인증 흐름이 순차적으로 안전하게 실행됩니다.


const { data: refreshTokenData, isLoading: isRefreshTokenLoading } = useGetRefreshToken();

if (!ticket) {
return <div>로그인을 다시 진행해주세요.</div>;
}

if (isLoading || isRefreshTokenLoading) {
return <Spinner />;
}

if (!data || !refreshTokenData) {
return <Spinner />;
}

if (refreshTokenData) {
authStorage.accessToken.set(refreshTokenData.accessToken);
}

return (
<div className='flex min-h-screen items-center justify-center'>
<div className='text-center'>
<div className='mb-4 text-2xl font-semibold'>
{isLoading ? '로그인 처리 중...' : '로그인 중입니다...'}
</div>

<div className='text-gray-500'>
{isLoading ? '서버와 통신 중입니다.' : '잠시만 기다려주세요.'}
</div>
</div>
</div>
);
}
Loading
Loading