Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/navermaps": "^3.7.9",
"@types/node": "^24.1.0",
"@types/qs": "^6.14.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
Expand Down
73 changes: 60 additions & 13 deletions src/api/axios.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios from 'axios';
import { BASE_URL } from './urls';
import authStore from '../store/authStore';

// 인증 필요 x
export const publicAxios = axios.create({
Expand All @@ -21,21 +22,67 @@ privateAxios.interceptors.request.use(
(error) => Promise.reject(error)
);

//privateAxios에 토큰이 없다면 로그인 페이지로 리다이렉트
let isRefreshing = false;
let requestQueue: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolve: (value: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reject: (reason?: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
config: any;
}[] = [];

// dev 환경에서는 baseURL을 빈 문자열로 설정해 프록시를 이용하도록 함
// 기존에 생성한 privateAxios를 이용하면 무한 루프가 발생할 수 있으므로 새로운 axios 인스턴스를 생성
function refreshAuthToken() {
const baseURL = import.meta.env.DEV ? '' : BASE_URL;
return axios.post('/api/auth/reissue-token', null, {
baseURL,
withCredentials: true,
});
}

privateAxios.interceptors.response.use(
(response) => {
return response;
},
(error) => {
const { response } = error;
if (response?.status === 401) {
const token = localStorage.getItem('accessToken');
if (token) {
console.warn('토큰이 유효하지 않습니다. ');
localStorage.removeItem('accessToken');
window.location.href = '/login'; // 또는 navigate('/login')
}
(response) => response,
async (error) => {
const { config, response } = error;
const originalRequest = config;
const isLoggedIn = authStore.getState().isLoggedIn;

if (isLoggedIn && response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

return new Promise((resolve, reject) => {
requestQueue.push({ resolve, reject, config: originalRequest });

if (!isRefreshing) {
isRefreshing = true;

refreshAuthToken()
.then((res) => {
const newToken = res.data.data.accessToken;
localStorage.setItem('accessToken', newToken);

// 큐에 쌓인 모든 요청 다시 실행
requestQueue.forEach(({ resolve, config }) => {
config.headers.Authorization = `Bearer ${newToken}`;
resolve(privateAxios(config));
});
requestQueue = [];
})
.catch((err) => {
requestQueue.forEach(({ reject }) => reject(err));
requestQueue = [];
localStorage.removeItem('accessToken');
window.location.replace('/login');
})
.finally(() => {
isRefreshing = false;
});
}
});
}

return Promise.reject(error);
}
);
12 changes: 9 additions & 3 deletions src/api/searchApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,21 @@ export const getRelatedSearchWords = async (
}
};

export const getRelatedSearchPlaces = async (keyword: string) => {
export const getRelatedSearchPlaces = async (
keyword: string,
userLat?: number,
userLng?: number
) => {
try {
const res = await privateAxios.get(ENDPOINT.PLACE_SEARCH, {
params: {
keyword: keyword,
keyword,
userLat,
userLng,
},
});

return res.data.data.contents;
return res.data.data;
} catch (e) {
console.error(e);
}
Expand Down
6 changes: 5 additions & 1 deletion src/auth/OAuthCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useShallow } from 'zustand/shallow';
import useLocationStore from '../store/locationStore';
import { publicAxios } from '../api/axios';
import { ENDPOINT } from '../api/urls';
import axios from 'axios';

const OAuthCallback = () => {
const { search } = useLocation();
Expand Down Expand Up @@ -36,7 +37,10 @@ const OAuthCallback = () => {
return;
}

publicAxios
//dev 환경에서는 baseURL을 빈 문자열로 설정해 프록시를 이용하도록 함
const client = import.meta.env.DEV ? axios : publicAxios;

client
.get(ENDPOINT.OAUTH_CALLBACK(loginType.toUpperCase()), {
params: { code },
})
Expand Down
5 changes: 4 additions & 1 deletion src/components/Place/PreviewContentSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ const PreviewContentSummary: React.FC<SummaryProps> = ({
{/* right */}
{/* preview */}
{!isSolroute ? (
<SolmarkChip placeId={place.id} isMarked={isMarked} />
<SolmarkChip
placeId={place.id}
isMarked={place.isMarked || isMarked}
/>
) : (
//쏠루트에서 해당 컴포넌트를 호출할 때는 항상 SolroutePreviewSummary type을 받아야 함
<SelectableChip place={place as SolroutePreviewSummary} />
Expand Down
24 changes: 2 additions & 22 deletions src/components/Searching/RelatedSearchList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import SearchTitle from './SearchTitle';
import { useSearchStore } from '../../store/searchStore';
import useDebounce from '../../hooks/useDebounce';
import { useQuery } from '@tanstack/react-query';
import {
getRelatedSearchPlaces,
getRelatedSearchWords,
} from '../../api/searchApi';
import { getRelatedSearchWords } from '../../api/searchApi';
import { useShallow } from 'zustand/shallow';
import { useMapStore } from '../../store/mapStore';

Expand Down Expand Up @@ -37,28 +34,11 @@ const RelatedSearchList: React.FC = () => {
enabled: debouncedInput !== '' && !!userLatLng,
});

// 입력값과 관련된 장소들을 RelatedSearchPlace type으로 가져옴
const { data: dataNoLoc, isSuccess: successNoLoc } = useQuery({
queryKey: ['RSList', debouncedInput],
queryFn: () => {
return getRelatedSearchPlaces(debouncedInput);
},
enabled: debouncedInput !== '' && userLatLng === null,
});

useEffect(() => {
if (successWithLoc) {
setRelatedSearchList(dataWithLoc);
} else if (successNoLoc && dataNoLoc) {
setRelatedSearchList(dataNoLoc);
}
}, [
setRelatedSearchList,
successWithLoc,
successNoLoc,
dataWithLoc,
dataNoLoc,
]);
}, [setRelatedSearchList, successWithLoc, dataWithLoc]);

return (
<div className='flex flex-col items-start'>
Expand Down
8 changes: 7 additions & 1 deletion src/components/Searching/RelatedSearchPlaceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useQuery } from '@tanstack/react-query';
import { getRelatedSearchPlaces } from '../../api/searchApi';
import { useShallow } from 'zustand/shallow';
import RelatedSearchPlace from './RelatedSearchPlace';
import { useMapStore } from '../../store/mapStore';

const RelatedSearchPlaceList: React.FC = () => {
//search와 관련된 store
Expand All @@ -18,13 +19,18 @@ const RelatedSearchPlaceList: React.FC = () => {
}))
);

const { userLatLng } = useMapStore();
const debouncedInput = useDebounce(inputValue, 500);

// 입력값과 관련된 장소들을 RelatedSearchPlace type으로 가져옴
const { data, isSuccess, error } = useQuery({
queryKey: ['RSList', debouncedInput],
queryFn: () => {
return getRelatedSearchPlaces(debouncedInput);
return getRelatedSearchPlaces(
debouncedInput,
userLatLng?.lat,
userLatLng?.lng
);
},
enabled: debouncedInput !== '',
});
Expand Down
41 changes: 30 additions & 11 deletions src/components/Sollect/SollectDetail/AddCourseButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,60 @@ import { SolroutePayload } from '../../../types';
import { queryClient } from '../../../main';
import { useSollectDetailStore } from '../../../store/sollectDetailStore';
import LoginRequiredAction from '../../../auth/LoginRequiredAction';
import Success from '../../global/Success';
import { toast } from 'react-toastify';
import { useState } from 'react';
import Modal from '../../global/Modal';
import { useNavigate } from 'react-router-dom';

const AddCourseButton = () => {
const [showModal, setShowModal] = useState(false);
const { title, placeSummaries } = useSollectDetailStore();
const navigate = useNavigate();

const mutation = useMutation({
mutationFn: (payload: SolroutePayload) => postSolroute(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['solroutes'] });
},
});
const clickButton = async () => {

const saveCourse = async () => {
const payload: SolroutePayload = {
iconId: 1,
name: title ?? '제목 없는 쏠렉트',
placeInfos: placeSummaries.map((place, i) => ({
id: place.id,
seq: i + 1,
seq: i + 1, // 순서 아이콘 1부터 시작
memo: '',
})),
};
console.log(payload);
// add to course
await mutation.mutateAsync(payload);
queryClient.invalidateQueries({ queryKey: ['solroutes'] });
toast(<Success title='코스로 저장됐습니다.' />, { autoClose: 2000 });
setShowModal(true);
};

const style =
'rounded-full border-1 border-primary-700 py-4 pr-16 pl-8 flex text-sm font-bold items-center';
return (
<LoginRequiredAction onAction={clickButton}>
<button className={'bg-white text-primary-700 ' + style}>
<img src={addBlack} alt='add' className='w-24 h-24' /> 코스로 저장
</button>
</LoginRequiredAction>
<>
<LoginRequiredAction onAction={saveCourse}>
<button className={'bg-white text-primary-700 ' + style}>
<img src={addBlack} alt='add' className='w-24 h-24' /> 코스로 저장
</button>
</LoginRequiredAction>
{showModal && (
<Modal
title='코스로 저장 완료!'
subtitle='저장한 코스를 쏠루트에서 확인할 수 있어요'
leftText='쏠렉트 이어보기'
rightText='쏠루트 보러가기'
onLeftClick={() => setShowModal(false)}
onRightClick={() => {
navigate('/solroute');
}}
/>
)}
</>
);
};

Expand Down
15 changes: 13 additions & 2 deletions src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@ import { useNavigate } from 'react-router-dom';
import loginBackground from '../assets/loginBackground.jpg';
import Logo from '../assets/SolepliLogoLogin.svg?react';
import LoginButtons from '../auth/LoginButtons';
import { useShallow } from 'zustand/shallow';
import useAuthStore from '../store/authStore';

const Login = () => {
const navigate = useNavigate();
const { logout } = useAuthStore(
useShallow((state) => ({
logout: state.logout,
}))
);
const [showButtons, setShowButtons] = useState(false);

useEffect(() => {
Expand All @@ -15,6 +22,11 @@ const Login = () => {
return () => clearTimeout(timer);
}, []);

const onClick = () => {
logout();
navigate('/', { replace: true });
};

return (
<div
className='h-dvh w-full flex flex-col justify-center items-center'
Expand All @@ -34,8 +46,7 @@ const Login = () => {
<LoginButtons />
<div
className='text-grayScale-400 text-xs font-medium underline leading-none text-center pt-24 button'
onClick={() => navigate('/', { replace: true })}
>
onClick={onClick}>
비회원으로 이용하기
</div>
</div>
Expand Down
Loading
Loading