diff --git a/index.html b/index.html
index 97af30f..90e44d7 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,7 @@
+
Quespot
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..4ac6512
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/pwa-maskable-512x512.png b/public/pwa-maskable-512x512.png
deleted file mode 100644
index 4ddd703..0000000
Binary files a/public/pwa-maskable-512x512.png and /dev/null differ
diff --git a/src/apis/auth.ts b/src/apis/auth.ts
index ac9aca7..cd05e6d 100644
--- a/src/apis/auth.ts
+++ b/src/apis/auth.ts
@@ -1,4 +1,5 @@
import { getAccessToken } from "@/utils/auth";
+import { apiClient } from "@/apis/client";
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site").replace(/\/$/, "");
const FRONTEND_REDIRECT_URI = import.meta.env.PROD
@@ -119,19 +120,12 @@ const requestLoginMethods = async (method: "GET" | "POST" | "DELETE", path =
const accessToken = getAccessToken();
if (!accessToken) throw new ApiError("로그인이 필요합니다.", 401);
- let response: Response;
- try {
- response = await fetch(`${API_BASE_URL}/api/users/me/login-methods${path}`, {
- method,
- credentials: "include",
- headers: { Authorization: `Bearer ${accessToken}` },
- });
- } catch {
- throw new ApiError("서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", 0);
- }
-
- const payload = await response.json().catch(() => null) as ApiEnvelope | null;
- if (!response.ok || !payload?.isSuccess) {
+ const response = await apiClient.request>({
+ url: `/api/users/me/login-methods${path}`,
+ method,
+ });
+ const payload = response.data;
+ if (!payload.isSuccess) {
throw new ApiError(
payload?.message || "로그인 수단 요청을 처리하지 못했습니다.",
response.status,
@@ -156,19 +150,9 @@ export const logout = async (): Promise => {
const accessToken = getAccessToken();
if (!accessToken) return;
- let response: Response;
- try {
- response = await fetch(`${API_BASE_URL}/api/auth/logout`, {
- method: "POST",
- credentials: "include",
- headers: { Authorization: `Bearer ${accessToken}` },
- });
- } catch {
- throw new ApiError("서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", 0);
- }
-
- const payload = await response.json().catch(() => null) as ApiEnvelope | null;
- if (!response.ok || !payload?.isSuccess) {
+ const response = await apiClient.post>("/api/auth/logout");
+ const payload = response.data;
+ if (!payload.isSuccess) {
throw new ApiError(
payload?.message || "로그아웃을 처리하지 못했습니다.",
response.status,
@@ -181,19 +165,9 @@ export const withdraw = async (): Promise => {
const accessToken = getAccessToken();
if (!accessToken) throw new ApiError("로그인이 필요합니다.", 401);
- let response: Response;
- try {
- response = await fetch(`${API_BASE_URL}/api/auth/withdraw`, {
- method: "DELETE",
- credentials: "include",
- headers: { Authorization: `Bearer ${accessToken}` },
- });
- } catch {
- throw new ApiError("서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", 0);
- }
-
- const payload = await response.json().catch(() => null) as ApiEnvelope | null;
- if (!response.ok || !payload?.isSuccess) {
+ const response = await apiClient.delete>("/api/auth/withdraw");
+ const payload = response.data;
+ if (!payload.isSuccess) {
throw new ApiError(
payload?.message || "회원탈퇴를 처리하지 못했습니다. 잠시 후 다시 시도해주세요.",
response.status,
diff --git a/src/apis/client.ts b/src/apis/client.ts
index e4d0a00..b02f6b1 100644
--- a/src/apis/client.ts
+++ b/src/apis/client.ts
@@ -1,5 +1,6 @@
-import { getAccessToken } from "@/utils/auth";
-import axios, { AxiosHeaders } from "axios";
+import { PATH } from "@/routes/paths";
+import { clearAuth, getAccessToken, saveAccessToken } from "@/utils/auth";
+import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
const BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site";
@@ -9,6 +10,26 @@ const DEV_ACCESS_TOKEN = import.meta.env.DEV
? (import.meta.env.VITE_DEV_ACCESS_TOKEN as string | undefined)
: undefined;
+type ApiEnvelope = {
+ isSuccess: boolean;
+ code: string;
+ message: string;
+ result: T;
+ errorDetail?: unknown;
+};
+
+type ReissueResult = {
+ accessToken: string;
+ profileCompleted: boolean;
+};
+
+type RetriableRequestConfig = InternalAxiosRequestConfig & {
+ _retry?: boolean;
+};
+
+const ACCESS_TOKEN_EXPIRED_CODE = "AUTH_401_003";
+let reissuePromise: Promise | null = null;
+
function normalizeBearerToken(token: string) {
const trimmedToken = token.trim();
@@ -34,9 +55,50 @@ function setAuthorizationHeader(headers: unknown, token: string) {
}
}
+async function reissueAccessToken() {
+ const response = await axios.post>(
+ `${BASE_URL}/api/auth/reissue`,
+ undefined,
+ {
+ withCredentials: true,
+ headers: {
+ Accept: "application/json",
+ },
+ },
+ );
+
+ const accessToken = response.data.result?.accessToken;
+
+ if (!response.data.isSuccess || !accessToken) {
+ throw new Error(response.data.message || "토큰을 재발급하지 못했습니다.");
+ }
+
+ saveAccessToken(accessToken);
+ return accessToken;
+}
+
+function getReissuedAccessToken() {
+ if (!reissuePromise) {
+ reissuePromise = reissueAccessToken().finally(() => {
+ reissuePromise = null;
+ });
+ }
+
+ return reissuePromise;
+}
+
+function redirectToLogin() {
+ clearAuth();
+
+ if (window.location.pathname !== PATH.LOGIN) {
+ window.location.replace(PATH.LOGIN);
+ }
+}
+
export const apiClient = axios.create({
baseURL: BASE_URL,
timeout: 10000,
+ withCredentials: true,
headers: {
"Content-Type": "application/json",
},
@@ -63,15 +125,33 @@ apiClient.interceptors.request.use((config) => {
apiClient.interceptors.response.use(
(response) => response,
- (error) => {
+ async (error) => {
const status = error.response?.status;
+ const code = error.response?.data?.code;
+ const originalRequest = error.config as RetriableRequestConfig | undefined;
+
+ if (status !== 401) {
+ return Promise.reject(error);
+ }
- if (status === 401) {
- console.warn(
- "인증 오류가 발생했습니다. accessToken 만료 또는 권한 문제를 확인해주세요.",
- );
+ if (
+ code === ACCESS_TOKEN_EXPIRED_CODE &&
+ originalRequest &&
+ !originalRequest._retry
+ ) {
+ originalRequest._retry = true;
+
+ try {
+ const accessToken = await getReissuedAccessToken();
+ setAuthorizationHeader(originalRequest.headers, accessToken);
+ return apiClient(originalRequest);
+ } catch (reissueError) {
+ redirectToLogin();
+ return Promise.reject(reissueError);
+ }
}
+ redirectToLogin();
return Promise.reject(error);
},
);
diff --git a/src/apis/like.ts b/src/apis/like.ts
index 7ab0a3a..3eb6726 100644
--- a/src/apis/like.ts
+++ b/src/apis/like.ts
@@ -1,9 +1,5 @@
import { ApiError } from "@/apis/auth";
-import { getAccessToken } from "@/utils/auth";
-
-const API_BASE_URL = (
- import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site"
-).replace(/\/$/, "");
+import { apiClient } from "@/apis/client";
type ApiEnvelope = {
isSuccess: boolean;
@@ -19,35 +15,13 @@ async function likeRequest(
method: "POST" | "DELETE";
},
): Promise {
- const accessToken = getAccessToken();
-
- if (!accessToken) {
- throw new ApiError("로그인이 필요합니다.", 401);
- }
-
- let response: Response;
-
- try {
- response = await fetch(`${API_BASE_URL}${path}`, {
- method: options.method,
- credentials: "include",
- headers: {
- Authorization: `Bearer ${accessToken}`,
- Accept: "application/json",
- },
- });
- } catch {
- throw new ApiError(
- "서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.",
- 0,
- );
- }
-
- const payload = (await response.json().catch(() => null)) as
- | ApiEnvelope
- | null;
+ const response = await apiClient.request>({
+ url: path,
+ method: options.method,
+ });
+ const payload = response.data;
- if (!response.ok || !payload?.isSuccess) {
+ if (!payload.isSuccess) {
throw new ApiError(
payload?.message || "좋아요 요청을 처리하지 못했습니다.",
response.status,
@@ -80,4 +54,4 @@ export function unlikeMissionCourse(courseId: number) {
return likeRequest(`/api/mission-courses/${courseId}/like`, {
method: "DELETE",
});
-}
\ No newline at end of file
+}
diff --git a/src/apis/mission.ts b/src/apis/mission.ts
index 6948b5f..c0a6901 100644
--- a/src/apis/mission.ts
+++ b/src/apis/mission.ts
@@ -1,4 +1,4 @@
-import { ApiError } from "@/apis/auth";
+import { apiClient } from "@/apis/client";
import type {
GetMissionDetailParams,
GetMissionsParams,
@@ -7,11 +7,6 @@ import type {
MissionListResponse,
RecommendedMissionListResponse,
} from "@/types/mission";
-import { getAccessToken } from "@/utils/auth";
-
-const API_BASE_URL = (
- import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site"
-).replace(/\/$/, "");
function createMissionQueryParams(params: GetMissionsParams) {
const searchParams = new URLSearchParams();
@@ -55,60 +50,25 @@ function createMissionDetailQueryParams(params: GetMissionDetailParams) {
return searchParams;
}
-async function missionRequest(url: string): Promise {
- const accessToken = getAccessToken();
-
- if (!accessToken) {
- throw new ApiError("로그인이 필요합니다.", 401);
- }
-
- let response: Response;
-
- try {
- response = await fetch(url, {
- method: "GET",
- credentials: "include",
- headers: {
- Authorization: `Bearer ${accessToken}`,
- Accept: "application/json",
- },
- });
- } catch {
- throw new ApiError(
- "서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.",
- 0,
- );
- }
-
- const payload = (await response.json().catch(() => null)) as T | null;
-
- if (!response.ok || !payload) {
- throw new ApiError(
- "미션 요청을 처리하지 못했습니다.",
- response.status,
- );
- }
-
- return payload;
-}
-
export async function getMissions(params: GetMissionsParams) {
const searchParams = createMissionQueryParams(params);
- return missionRequest(
- `${API_BASE_URL}/api/missions?${searchParams.toString()}`,
+ const { data } = await apiClient.get(
+ `/api/missions?${searchParams.toString()}`,
);
+ return data;
}
export async function getMissionDetail(params: GetMissionDetailParams) {
const searchParams = createMissionDetailQueryParams(params);
const queryString = searchParams.toString();
- return missionRequest(
- `${API_BASE_URL}/api/missions/${params.missionId}${
+ const { data } = await apiClient.get(
+ `/api/missions/${params.missionId}${
queryString ? `?${queryString}` : ""
}`,
);
+ return data;
}
export async function getRecommendedMissions(
@@ -129,7 +89,8 @@ export async function getRecommendedMissions(
searchParams.set("size", String(params.size ?? 20));
- return missionRequest(
- `${API_BASE_URL}/api/missions/recommendations?${searchParams.toString()}`,
+ const { data } = await apiClient.get(
+ `/api/missions/recommendations?${searchParams.toString()}`,
);
+ return data;
}
diff --git a/src/apis/missionAttempt.ts b/src/apis/missionAttempt.ts
index a0ae7c9..1242e71 100644
--- a/src/apis/missionAttempt.ts
+++ b/src/apis/missionAttempt.ts
@@ -1,5 +1,5 @@
import { ApiError } from "@/apis/auth";
-import { getAccessToken } from "@/utils/auth";
+import { apiClient } from "@/apis/client";
import type {
MissionArrivalResult,
MissionAttempt,
@@ -10,10 +10,6 @@ import type {
MissionVerificationGuide,
} from "@/types/missionAttempt";
-const API_BASE_URL = (
- import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site"
-).replace(/\/$/, "");
-
type ApiEnvelope = {
isSuccess: boolean;
code: string;
@@ -31,40 +27,15 @@ async function missionAttemptRequest(
body?: unknown;
},
): Promise {
- const accessToken = getAccessToken();
-
- if (!accessToken) {
- throw new ApiError("로그인이 필요합니다.", 401);
- }
-
const method = options?.method ?? "GET";
- const hasBody = options?.body !== undefined;
-
- let response: Response;
-
- try {
- response = await fetch(`${API_BASE_URL}${path}`, {
- method,
- credentials: "include",
- headers: {
- Authorization: `Bearer ${accessToken}`,
- Accept: "application/json",
- ...(hasBody ? { "Content-Type": "application/json" } : {}),
- },
- ...(hasBody ? { body: JSON.stringify(options.body) } : {}),
- });
- } catch {
- throw new ApiError(
- "서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.",
- 0,
- );
- }
-
- const payload = (await response.json().catch(() => null)) as
- | ApiEnvelope
- | null;
-
- if (!response.ok || !payload?.isSuccess) {
+ const response = await apiClient.request>({
+ url: path,
+ method,
+ data: options?.body,
+ });
+ const payload = response.data;
+
+ if (!payload.isSuccess) {
throw new ApiError(
payload?.message || "미션 인증 요청을 처리하지 못했습니다.",
response.status,
@@ -168,4 +139,4 @@ export function quitMissionAttempt(attemptId: number) {
method: "POST",
},
);
-}
\ No newline at end of file
+}
diff --git a/src/apis/missionCourse.ts b/src/apis/missionCourse.ts
index 5468327..ec34202 100644
--- a/src/apis/missionCourse.ts
+++ b/src/apis/missionCourse.ts
@@ -1,5 +1,5 @@
import { ApiError } from "@/apis/auth";
-import { getAccessToken } from "@/utils/auth";
+import { apiClient } from "@/apis/client";
import type {
CourseAttempt,
CreateMissionCourseRequest,
@@ -7,10 +7,6 @@ import type {
MissionCourseItem,
} from "@/types/missionCourse";
-const API_BASE_URL = (
- import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site"
-).replace(/\/$/, "");
-
type ApiEnvelope = {
isSuccess: boolean;
code: string;
@@ -28,40 +24,15 @@ async function missionCourseRequest(
body?: unknown;
},
): Promise {
- const accessToken = getAccessToken();
-
- if (!accessToken) {
- throw new ApiError("로그인이 필요합니다.", 401);
- }
-
const method = options?.method ?? "GET";
- const hasBody = options?.body !== undefined;
-
- let response: Response;
-
- try {
- response = await fetch(`${API_BASE_URL}${path}`, {
- method,
- credentials: "include",
- headers: {
- Authorization: `Bearer ${accessToken}`,
- Accept: "application/json",
- ...(hasBody ? { "Content-Type": "application/json" } : {}),
- },
- ...(hasBody ? { body: JSON.stringify(options.body) } : {}),
- });
- } catch {
- throw new ApiError(
- "서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.",
- 0,
- );
- }
-
- const payload = (await response.json().catch(() => null)) as
- | ApiEnvelope
- | null;
+ const response = await apiClient.request>({
+ url: path,
+ method,
+ data: options?.body,
+ });
+ const payload = response.data;
- if (!response.ok || !payload?.isSuccess) {
+ if (!payload.isSuccess) {
throw new ApiError(
payload?.message || "미션 코스 요청을 처리하지 못했습니다.",
response.status,
@@ -102,4 +73,4 @@ export function quitCourseAttempt(courseAttemptId: number) {
method: "POST",
},
);
-}
\ No newline at end of file
+}
diff --git a/src/apis/profile.ts b/src/apis/profile.ts
index a679b8b..fa1fd26 100644
--- a/src/apis/profile.ts
+++ b/src/apis/profile.ts
@@ -1,8 +1,6 @@
import { ApiError } from "@/apis/auth";
+import { apiClient } from "@/apis/client";
import type { TravelStyle } from "@/constants/profile";
-import { getAccessToken } from "@/utils/auth";
-
-const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site").replace(/\/$/, "");
interface ApiEnvelope {
isSuccess: boolean;
@@ -38,26 +36,13 @@ export type BasicProfileUpdate = Pick(method: "GET" | "POST" | "PATCH", body?: unknown): Promise {
- const accessToken = getAccessToken();
- if (!accessToken) throw new ApiError("로그인이 필요합니다.", 401);
-
- let response: Response;
- try {
- response = await fetch(`${API_BASE_URL}/api/users/me/profile`, {
- method,
- credentials: "include",
- headers: {
- Authorization: `Bearer ${accessToken}`,
- ...(body === undefined ? {} : { "Content-Type": "application/json" }),
- },
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
- });
- } catch {
- throw new ApiError("서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", 0);
- }
-
- const payload = await response.json().catch(() => null) as ApiEnvelope | null;
- if (!response.ok || !payload?.isSuccess) {
+ const response = await apiClient.request>({
+ url: "/api/users/me/profile",
+ method,
+ data: body,
+ });
+ const payload = response.data;
+ if (!payload.isSuccess) {
throw new ApiError(payload?.message || "프로필 요청을 처리하지 못했습니다.", response.status, payload?.code);
}
return payload.result;
diff --git a/src/routes/router.tsx b/src/routes/router.tsx
index ccfc3ae..ffd09b3 100644
--- a/src/routes/router.tsx
+++ b/src/routes/router.tsx
@@ -45,7 +45,7 @@ export const router = createBrowserRouter([
children: [
{
path: PATH.LANDING,
- element: ,
+ element: ,
},
{
path: PATH.LOGIN,
diff --git a/src/utils/auth.ts b/src/utils/auth.ts
index 5ab010a..268757d 100644
--- a/src/utils/auth.ts
+++ b/src/utils/auth.ts
@@ -41,6 +41,10 @@ export const saveAuth = (accessToken: string, userId: number) => {
localStorage.setItem(USER_ID_KEY, String(userId));
};
+export const saveAccessToken = (accessToken: string) => {
+ localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
+};
+
export const getAccessToken = () => localStorage.getItem(ACCESS_TOKEN_KEY);
export const beginSocialLogin = (provider: SocialProviderId) =>
diff --git a/vite.config.ts b/vite.config.ts
index d26f942..bf63714 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -37,12 +37,6 @@ export default defineConfig({
type: "image/png",
purpose: "any",
},
- {
- src: "/pwa-maskable-512x512.png",
- sizes: "512x512",
- type: "image/png",
- purpose: "maskable",
- },
],
},
}),