diff --git a/src/entities/auth/apis/get-ticket.api.ts b/src/entities/auth/apis/get-ticket.api.ts index 60f27c3..c04aa11 100644 --- a/src/entities/auth/apis/get-ticket.api.ts +++ b/src/entities/auth/apis/get-ticket.api.ts @@ -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 => { - const response = await fetchInstance.get(GET_TICKET_API_PATH, { + const response = await fetchInstance.get(GET_TICKET_API_PATH(), { params: { ticket, }, }); + return response.data; }; diff --git a/src/entities/auth/apis/index.ts b/src/entities/auth/apis/index.ts index 5cbea31..97ec6b7 100644 --- a/src/entities/auth/apis/index.ts +++ b/src/entities/auth/apis/index.ts @@ -1 +1,2 @@ export * from './get-ticket.api'; +export * from './refresh-token.api'; diff --git a/src/entities/auth/apis/refresh-token.api.ts b/src/entities/auth/apis/refresh-token.api.ts new file mode 100644 index 0000000..3fbed2c --- /dev/null +++ b/src/entities/auth/apis/refresh-token.api.ts @@ -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 => { + const response = await fetchInstance.post(REFRESH_TOKEN_API_PATH, { + refreshToken, + }); + + return response.data; +}; diff --git a/src/entities/auth/hooks/getAuthTicket.ts b/src/entities/auth/hooks/getAuthTicket.ts deleted file mode 100644 index 0dbd3f2..0000000 --- a/src/entities/auth/hooks/getAuthTicket.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { getTicketApi } from '../apis'; - -export const GetAuthTicketQueryKey = { - ticket: (ticket: string) => ['ticket', ticket], -}; - -export const useGetAuthTicket = (ticket: string) => { - return useQuery({ - queryKey: GetAuthTicketQueryKey.ticket(ticket), - queryFn: () => getTicketApi(ticket), - }); -}; diff --git a/src/entities/auth/hooks/index.ts b/src/entities/auth/hooks/index.ts index 83322f7..763029c 100644 --- a/src/entities/auth/hooks/index.ts +++ b/src/entities/auth/hooks/index.ts @@ -1,2 +1,3 @@ -export * from './getAuthTicket'; +export * from './useGetAuthTicket'; export * from './useKakaoLogin'; +export * from './useGetRefreshToken'; diff --git a/src/entities/auth/hooks/useGetAuthTicket.ts b/src/entities/auth/hooks/useGetAuthTicket.ts new file mode 100644 index 0000000..b231a89 --- /dev/null +++ b/src/entities/auth/hooks/useGetAuthTicket.ts @@ -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, + }); +}; diff --git a/src/entities/auth/hooks/useGetRefreshToken.ts b/src/entities/auth/hooks/useGetRefreshToken.ts new file mode 100644 index 0000000..b5579ad --- /dev/null +++ b/src/entities/auth/hooks/useGetRefreshToken.ts @@ -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(), + }); +}; diff --git a/src/entities/auth/hooks/useKakaoLogin.ts b/src/entities/auth/hooks/useKakaoLogin.ts index 252b62d..12dde23 100644 --- a/src/entities/auth/hooks/useKakaoLogin.ts +++ b/src/entities/auth/hooks/useKakaoLogin.ts @@ -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), }); }; diff --git a/src/entities/chart/apis/index.ts b/src/entities/chart/apis/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/entities/chart/apis/size-price.api.ts b/src/entities/chart/apis/size-price.api.ts new file mode 100644 index 0000000..8e01849 --- /dev/null +++ b/src/entities/chart/apis/size-price.api.ts @@ -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 => { + const response = await fetchInstance.get(SIZE_PRICE_API_PATH); + + return response.data; +}; diff --git a/src/features/login/components/common/buttons/KakaoLoginButton.tsx b/src/features/login/components/common/buttons/KakaoLoginButton.tsx index 9f5688e..4150eaf 100644 --- a/src/features/login/components/common/buttons/KakaoLoginButton.tsx +++ b/src/features/login/components/common/buttons/KakaoLoginButton.tsx @@ -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 ( ); }; diff --git a/src/features/main/apis/diagnosis.api.ts b/src/features/main/apis/diagnosis.api.ts index 58bc7e7..a3efad4 100644 --- a/src/features/main/apis/diagnosis.api.ts +++ b/src/features/main/apis/diagnosis.api.ts @@ -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; diff --git a/src/features/main/components/features/form/DiagnosticForm.tsx b/src/features/main/components/features/form/DiagnosticForm.tsx index 6c00bf6..7450513 100644 --- a/src/features/main/components/features/form/DiagnosticForm.tsx +++ b/src/features/main/components/features/form/DiagnosticForm.tsx @@ -1,3 +1,4 @@ +import { type RefObject } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -17,7 +18,11 @@ import { RentTypeField, } from '../../common'; -export const DiagnosticForm = () => { +type Props = { + scrollToRiskAnalysis: RefObject; +}; + +export const DiagnosticForm = ({ scrollToRiskAnalysis }: Props) => { const { setDiagnosisData } = useHouseData(); const form = useForm({ @@ -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); }, }); diff --git a/src/features/main/constants/form-type.ts b/src/features/main/constants/form-type.ts index fba620c..2959f8b 100644 --- a/src/features/main/constants/form-type.ts +++ b/src/features/main/constants/form-type.ts @@ -9,7 +9,7 @@ export const FORM_FIELDS = { export const PLACEHOLDER_TEXTS = { ADDRESS: '주소를 알려주세요', HOUSE_TYPE: '주택유형을 알려주세요', - DETAIL_ADDRESS: '상세주소를 알려주세요.', + DETAIL_ADDRESS: '상세주소를 알려주세요', DEPOSIT: '전세금액', } as const; diff --git a/src/features/main/ui/InputSection.tsx b/src/features/main/ui/InputSection.tsx index bcd4819..01a3b2d 100644 --- a/src/features/main/ui/InputSection.tsx +++ b/src/features/main/ui/InputSection.tsx @@ -1,6 +1,12 @@ +import { type RefObject } from 'react'; + import { DiagnosticForm } from '../components'; -export const InputSection = () => { +type Props = { + scrollToRiskAnalysis: RefObject; +}; + +export const InputSection = ({ scrollToRiskAnalysis }: Props) => { return (
@@ -11,7 +17,7 @@ export const InputSection = () => { 안심할 수 있는지 확인해봐요!
- +
); diff --git a/src/features/main/ui/RiskAnalysisSummarySection.tsx b/src/features/main/ui/RiskAnalysisSummarySection.tsx index f05cbe1..0f6d1ce 100644 --- a/src/features/main/ui/RiskAnalysisSummarySection.tsx +++ b/src/features/main/ui/RiskAnalysisSummarySection.tsx @@ -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((_, ref) => { const { diagnosisData, isLoading, error } = useHouseData(); if (isLoading) return ; @@ -20,7 +22,10 @@ export const RiskAnalysisSummarySection = () => { } return ( -
+
{/* 메인 위험도 분석 섹션 */}
{/* 왼쪽: 위험도 게이지 */} @@ -30,4 +35,6 @@ export const RiskAnalysisSummarySection = () => {
); -}; +}); + +RiskAnalysisSummarySection.displayName = 'RiskAnalysisSummarySection'; diff --git a/src/pages/main/MainPage.tsx b/src/pages/main/MainPage.tsx index 8d07d37..ac7202c 100644 --- a/src/pages/main/MainPage.tsx +++ b/src/pages/main/MainPage.tsx @@ -1,3 +1,5 @@ +import { useRef } from 'react'; + import { AlternativeSection, ChartSection, @@ -9,13 +11,15 @@ import { } from '@/features'; export default function MainPage() { + const riskAnalysisRef = useRef(null); + return (
- + } />
- + diff --git a/src/pages/oauth/OAuthRedirectPage.tsx b/src/pages/oauth/OAuthRedirectPage.tsx index 293c02a..31d5a05 100644 --- a/src/pages/oauth/OAuthRedirectPage.tsx +++ b/src/pages/oauth/OAuthRedirectPage.tsx @@ -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
로그인 중입니다...
; + }, [data, navigate]); + + const { data: refreshTokenData, isLoading: isRefreshTokenLoading } = useGetRefreshToken(); + + if (!ticket) { + return
로그인을 다시 진행해주세요.
; + } + + if (isLoading || isRefreshTokenLoading) { + return ; + } + + if (!data || !refreshTokenData) { + return ; + } + + if (refreshTokenData) { + authStorage.accessToken.set(refreshTokenData.accessToken); + } + + return ( +
+
+
+ {isLoading ? '로그인 처리 중...' : '로그인 중입니다...'} +
+ +
+ {isLoading ? '서버와 통신 중입니다.' : '잠시만 기다려주세요.'} +
+
+
+ ); } diff --git a/src/shared/libs/fetch-instance.ts b/src/shared/libs/fetch-instance.ts index 74c88ae..eab5f96 100644 --- a/src/shared/libs/fetch-instance.ts +++ b/src/shared/libs/fetch-instance.ts @@ -1,9 +1,14 @@ +import { toast } from 'sonner'; + +import { authStorage } from '../utils'; + import { initInstance } from './axios-instance'; export const BASE_URL = 'http://43.200.101.253'; +// export const BASE_URL = 'http://localhost:8080'; export const fetchInstance = initInstance({ - baseURL: BASE_URL, + baseURL: '/api', headers: { 'Content-Type': 'application/json', }, @@ -15,3 +20,48 @@ fetchInstance.interceptors.request.use( }, (error) => Promise.reject(error), ); + +fetchInstance.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response.status === 500 && !originalRequest._retry) { + originalRequest._retry = true; + + const refreshToken = authStorage.refreshToken.get(); + + if (!refreshToken) { + return Promise.reject(error); + } + + const response = await fetch(`${BASE_URL}/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refreshToken }), + }); + + if (response.ok) { + console.log('refreshToken success'); + toast.success('재인증 성공!'); + const data = await response.json(); + + authStorage.refreshToken.set(data.refreshToken); + authStorage.accessToken.set(data.accessToken); + + return fetchInstance(originalRequest); + } else if (response.status === 401) { + console.log('refreshToken failed'); + toast.error('재인증 실패!'); + + authStorage.refreshToken.set(''); + authStorage.accessToken.set(''); + + return Promise.reject(error); + } + } + return Promise.reject(error); + }, +); diff --git a/src/shared/utils/auth-storage/auth-storage.ts b/src/shared/utils/auth-storage/auth-storage.ts index 1db7db0..195e4af 100644 --- a/src/shared/utils/auth-storage/auth-storage.ts +++ b/src/shared/utils/auth-storage/auth-storage.ts @@ -9,7 +9,7 @@ const initStorage = (key: T, storage: Storage) = const get = (): AuthStorageKey[T] => { const value = storage.getItem(storageKey); - return JSON.parse(value as string); + return value as AuthStorageKey[T]; }; const set = (value: AuthStorageKey[T]) => { @@ -17,9 +17,7 @@ const initStorage = (key: T, storage: Storage) = return storage.removeItem(storageKey); } - const stringifiedValue = JSON.stringify(value); - - storage.setItem(storageKey, stringifiedValue); + storage.setItem(storageKey, String(value)); }; return { get, set }; diff --git a/vite.config.ts b/vite.config.ts index 915831b..d40e92e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,4 +11,12 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + server: { + proxy: { + '/api': { + target: 'http://43.200.101.253', + changeOrigin: true, + }, + }, + }, });