diff --git a/app/global.css b/app/global.css index 74119091..bc9f1f89 100644 --- a/app/global.css +++ b/app/global.css @@ -225,6 +225,9 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E --color-background-accent-yellow-subtle: var(--color-yellow-50); --color-background-accent-yellow-default: var(--color-yellow-100); --color-background-accent-yellow-strong: var(--color-yellow-600); + --color-background-neutral-strong: var(--color-gray-900); + --color-background-success-default: var(--color-green-500); + --color-background-danger-default: var(--color-red-500); --color-fill-brand-default-default: var(--color-rose-500); --color-fill-brand-default-hover: var(--color-rose-600); diff --git a/app/login/page.tsx b/app/login/page.tsx index f8e28d58..fd388481 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -9,7 +9,5 @@ export const metadata: Metadata = { // 랜딩페이지에서 로그인 모달이 뜨는 것으로 파악 export default function LoginPage() { - console.log("NEXT_PUBLIC_API_BASE_URL", process.env.NEXT_PUBLIC_API_BASE_URL) - return ; } diff --git a/app/redirection/page.tsx b/app/redirection/page.tsx index 279307b3..97e271c1 100644 --- a/app/redirection/page.tsx +++ b/app/redirection/page.tsx @@ -49,13 +49,13 @@ function RedirectionContent() { handleRedirection().catch(console.error); }, [searchParams, router, queryClient]); // 의존성 추가 - return
처리중...
; + return <>; } // useSearchParams() should be wrapped in a suspense boundary export default function RedirectionPage() { return ( - 로딩중...}> + }> ); diff --git a/app/sign-up/page.tsx b/app/sign-up/page.tsx index 42e39f7d..8ddb2820 100644 --- a/app/sign-up/page.tsx +++ b/app/sign-up/page.tsx @@ -4,7 +4,6 @@ import Landing from '@/features/auth/ui/landing'; // 랜딩페이지에서 회원가입 모달이 뜨는 것으로 파악 export default function SignupPage() { // TODO : URL 에서 토큰 파싱후 - console.log('회원가입 페이지'); return ; } diff --git a/package.json b/package.json index 76b2ac30..0f8dca0d 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "react": "^19.0.0", "react-day-picker": "9.4.3", "react-dom": "^19.0.0", + "sonner": "^2.0.6", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.0.6", "tailwindcss-animate": "^1.0.7", diff --git a/src/app/provider/index.tsx b/src/app/provider/index.tsx index bc0968dc..42a04378 100644 --- a/src/app/provider/index.tsx +++ b/src/app/provider/index.tsx @@ -1,4 +1,5 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { Toaster } from 'sonner'; import QueryProvider from '@/app/provider/query-provider'; interface ProviderProps { @@ -8,6 +9,30 @@ interface ProviderProps { function MainProvider({ children }: ProviderProps) { return ( + {children} {process.env.NODE_ENV === 'development' && ( diff --git a/src/entities/user/api/get-user-profile.ts b/src/entities/user/api/get-user-profile.ts index 67b9f322..7261fed1 100644 --- a/src/entities/user/api/get-user-profile.ts +++ b/src/entities/user/api/get-user-profile.ts @@ -2,12 +2,12 @@ import type { GetUserProfileResponse, PatchAutoMatchingParams, } from '@/entities/user/api/types'; -import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { axiosClientInstance } from '@/shared/tanstack-query/axios.client'; export const getUserProfile = async ( memberId: number, ): Promise => { - const res = await axiosInstance.get(`/members/${memberId}/profile`); + const res = await axiosClientInstance.get(`/members/${memberId}/profile`); return res.data.content; }; @@ -16,7 +16,11 @@ export const patchAutoMatching = async ({ memberId, autoMatching, }: PatchAutoMatchingParams): Promise => { - await axiosInstance.patch(`/members/${memberId}/auto-matching`, undefined, { - params: { 'auto-matching': autoMatching }, - }); + await axiosClientInstance.patch( + `/members/${memberId}/auto-matching`, + undefined, + { + params: { 'auto-matching': autoMatching }, + }, + ); }; diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts index 26828943..fc50873f 100644 --- a/src/features/auth/api/auth.ts +++ b/src/features/auth/api/auth.ts @@ -1,14 +1,13 @@ // API 통신만 담당하는 순수 함수들 import { - axiosInstance, - axiosInstanceForMultipart, -} from '@/shared/tanstack-query/axios'; + axiosClientInstance, + axiosClientInstanceForMultipart, +} from '@/shared/tanstack-query/axios.client'; // 회원가입 요청 API export async function signUp(data: any) { - const res = await axiosInstance.post('/members', data); - console.log('signUp res', res); + const res = await axiosClientInstance.post('/members', data); return res.data; } @@ -19,7 +18,7 @@ export async function uploadProfileImage( filename: string, file: FormData, ) { - const res = await axiosInstanceForMultipart.put( + const res = await axiosClientInstanceForMultipart.put( `/files/members/${memberId}/profile/image/${filename}`, file, ); @@ -29,15 +28,15 @@ export async function uploadProfileImage( // 멤버 ID 조회 API export async function getMemberId() { - const res = await axiosInstance.get(`/auth/me`); - console.log('getMemberId res', res); + const res = await axiosClientInstance.get(`/auth/me`); return res.data; } // 로그아웃 API -export const logout = async (): Promise => { - const res = await axiosInstance.post('/auth/logout'); +// 성공하면, content는 빈배열로 응답 +export const logout = async (): Promise> => { + const res = await axiosClientInstance.post('/auth/logout'); - return res.data.statusCode; + return res.data.content; }; diff --git a/src/features/auth/model/use-auth-mutation.ts b/src/features/auth/model/use-auth-mutation.ts index 4ec5aa3c..6e327941 100644 --- a/src/features/auth/model/use-auth-mutation.ts +++ b/src/features/auth/model/use-auth-mutation.ts @@ -1,7 +1,11 @@ // 데이터 변경(Mutation) 을 담당하는 커스텀 훅 -import { useMutation } from '@tanstack/react-query'; +import { sendGTMEvent } from '@next/third-parties/google'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; import { logout, signUp, uploadProfileImage } from '@/features/auth/api/auth'; +import { hashValue } from '@/shared/lib/hash'; +import { deleteCookie, getCookie } from '@/shared/tanstack-query/cookie'; import { SignUpResponse } from './types'; // 회원가입 요청 커스텀 훅 @@ -27,7 +31,28 @@ export function useUploadProfileImageMutation() { } export const useLogoutMutation = () => { - return useMutation({ + const queryClient = useQueryClient(); + const router = useRouter(); + + return useMutation({ mutationFn: logout, + onSuccess: () => { + const memberId = getCookie('memberId'); + + if (memberId) + sendGTMEvent({ + event: 'custom_member_logout', + dl_timestamp: new Date().toISOString(), + dl_member_id: hashValue(memberId), + }); + + deleteCookie('accessToken'); + deleteCookie('memberId'); + + queryClient.clear(); + + router.push('/login'); + router.refresh(); + }, }); }; diff --git a/src/features/auth/ui/header-user-dropdown.tsx b/src/features/auth/ui/header-user-dropdown.tsx index 94292a47..b63fe292 100644 --- a/src/features/auth/ui/header-user-dropdown.tsx +++ b/src/features/auth/ui/header-user-dropdown.tsx @@ -1,48 +1,21 @@ 'use client'; -import { sendGTMEvent } from '@next/third-parties/google'; -import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; -import { hashValue } from '@/shared/lib/hash'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/shared/shadcn/ui/dropdown-menu'; -import { deleteCookie, getCookie } from '@/shared/tanstack-query/cookie'; import UserAvatar from '@/shared/ui/avatar'; -import { logout } from '../api/auth'; +import { useLogoutMutation } from '../model/use-auth-mutation'; export default function HeaderUserDropdown({ userImg }: { userImg: string }) { - const queryClient = useQueryClient(); const router = useRouter(); + const { mutateAsync: logout } = useLogoutMutation(); const handleLogout = async () => { - try { - // 1. 서버에 로그아웃 요청 (refresh token 삭제) - await logout(); - - const memberId = getCookie('memberId'); - sendGTMEvent({ - event: 'custom_member_logout', - dl_timestamp: new Date().toISOString(), - dl_member_id: hashValue(memberId), - }); - - // 2. 클라이언트의 access token 삭제 - deleteCookie('accessToken'); - deleteCookie('memberId'); - - // 3. React Query 캐시 초기화 - queryClient.clear(); - - // 4. 홈으로 리다이렉트 - router.push('/'); - router.refresh(); // 전체 페이지 리프레시 - } catch (error) { - console.error('로그아웃 실패:', error); - } + await logout(); }; return ( diff --git a/src/features/my-page/api/update-user-profile.ts b/src/features/my-page/api/update-user-profile.ts index 2d5b7877..e8b18d02 100644 --- a/src/features/my-page/api/update-user-profile.ts +++ b/src/features/my-page/api/update-user-profile.ts @@ -8,13 +8,16 @@ import type { UpdateUserProfileRequest, UpdateUserProfileResponse, } from '@/features/my-page/api/types'; -import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { axiosClientInstance } from '@/shared/tanstack-query/axios.client'; export const updateUserProfile = async ( memberId: number, body: UpdateUserProfileRequest, ): Promise => { - const res = await axiosInstance.patch(`/members/${memberId}/profile`, body); + const res = await axiosClientInstance.patch( + `/members/${memberId}/profile`, + body, + ); return res.data.content; }; @@ -23,7 +26,7 @@ export const updateUserProfileInfo = async ( memberId: number, body: UpdateUserProfileInfoRequest, ): Promise => { - const res = await axiosInstance.patch( + const res = await axiosClientInstance.patch( `/members/${memberId}/profile/info`, body, ); @@ -34,25 +37,25 @@ export const updateUserProfileInfo = async ( export const getAvailableStudyTimes = async (): Promise< AvailableStudyTimeResponse[] > => { - const res = await axiosInstance.get('/available-study-times'); + const res = await axiosClientInstance.get('/available-study-times'); return res.data.content; }; export const getStudySubjects = async (): Promise => { - const res = await axiosInstance.get('/study-subjects'); + const res = await axiosClientInstance.get('/study-subjects'); return res.data.content; }; export const getTechStacks = async (): Promise => { - const res = await axiosInstance.get('/tech-stacks'); + const res = await axiosClientInstance.get('/tech-stacks'); return res.data.content; }; export const getStudyDashboard = async (): Promise => { - const res = await axiosInstance.get('/study/dashboard'); + const res = await axiosClientInstance.get('/study/dashboard'); return res.data.content; }; diff --git a/src/features/study/api/get-study-data.ts b/src/features/study/api/get-study-data.ts index 89f1c674..e7256963 100644 --- a/src/features/study/api/get-study-data.ts +++ b/src/features/study/api/get-study-data.ts @@ -10,13 +10,13 @@ import type { PrepareStudyRequest, WeeklyParticipationResponse, } from '@/features/study/api/types'; -import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { axiosClientInstance } from '@/shared/tanstack-query/axios.client'; // 스터디 상세 조회 export const getDailyStudyDetail = async ( params: string, ): Promise => { - const res = await axiosInstance.get(`/study/daily/mine/${params}`); + const res = await axiosClientInstance.get(`/study/daily/mine/${params}`); return res.data.content; }; @@ -25,7 +25,7 @@ export const getDailyStudyDetail = async ( export const getDailyStudies = async ( params?: GetDailyStudiesParams, ): Promise => { - const res = await axiosInstance.get('/study/daily', { params }); + const res = await axiosClientInstance.get('/study/daily', { params }); return res.data.content; }; @@ -34,13 +34,13 @@ export const getDailyStudies = async ( export const getMonthlyStudyCalendar = async ( params: GetMonthlyCalendarParams, ): Promise => { - const res = await axiosInstance.get('/study/daily/month', { params }); + const res = await axiosClientInstance.get('/study/daily/month', { params }); return res.data.content; }; export const postDailyRetrospect = async (body: PostDailyRetrospectRequest) => { - const res = await axiosInstance.post('/study/daily/retrospect', body); + const res = await axiosClientInstance.post('/study/daily/retrospect', body); return res.data; }; @@ -50,7 +50,10 @@ export const putStudyDaily = async ( dailyId: number, body: PrepareStudyRequest, ) => { - const res = await axiosInstance.put(`/study/daily/${dailyId}/prepare`, body); + const res = await axiosClientInstance.put( + `/study/daily/${dailyId}/prepare`, + body, + ); return res.data; }; @@ -60,7 +63,7 @@ export const completeStudy = async ( dailyStudyId: number, body: CompleteStudyRequest, ) => { - const res = await axiosInstance.post( + const res = await axiosClientInstance.post( `/study/daily/${dailyStudyId}/complete`, body, ); @@ -79,7 +82,7 @@ export const postJoinStudy = async (payload: JoinStudyRequest) => { ), ); - const res = await axiosInstance.post('/matching/apply', cleanPayload); + const res = await axiosClientInstance.post('/matching/apply', cleanPayload); return res.data; }; @@ -93,4 +96,4 @@ export const getWeeklyParticipation = async ( }); return res.data.content; -}; +}; \ No newline at end of file diff --git a/src/features/study/model/use-study-query.ts b/src/features/study/model/use-study-query.ts index ab782631..78eda77b 100644 --- a/src/features/study/model/use-study-query.ts +++ b/src/features/study/model/use-study-query.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; import { completeStudy, getDailyStudies, @@ -8,6 +9,8 @@ import { postJoinStudy, putStudyDaily, } from '@/features/study/api/get-study-data'; +import { isApiError } from '@/shared/tanstack-query/api-error'; +import { openToast } from '@/shared/ui/toast'; import { CompleteStudyRequest, GetDailyStudiesParams, @@ -59,8 +62,22 @@ export const useMonthlyStudyCalendarQuery = ( // 스터디 신청 mutation export const useJoinStudyMutation = () => { + const router = useRouter(); + return useMutation({ mutationFn: (payload: JoinStudyRequest) => postJoinStudy(payload), + onSuccess: () => { + alert('스터디 신청이 완료되었습니다!'); + router.refresh(); + }, + onError: (error) => { + if (isApiError(error)) { + openToast({ + type: 'danger', + text: '스터디 신청에 실패했습니다.', + }); + } + }, }); }; diff --git a/src/features/study/ui/start-study-modal.tsx b/src/features/study/ui/start-study-modal.tsx index 4c3a190e..8a06cc20 100644 --- a/src/features/study/ui/start-study-modal.tsx +++ b/src/features/study/ui/start-study-modal.tsx @@ -2,7 +2,6 @@ import { XIcon } from 'lucide-react'; import Image from 'next/image'; -import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { useAvailableStudyTimesQuery, @@ -151,7 +150,6 @@ function StartStudyForm({ memberId }: StartStudyModalProps) { const { data: availableStudyTimes } = useAvailableStudyTimesQuery(); const { data: studySubjects } = useStudySubjectsQuery(); const { data: techStacks } = useTechStacksQuery(); - const router = useRouter(); const { mutate: joinStudy } = useJoinStudyMutation(); @@ -187,23 +185,12 @@ function StartStudyForm({ memberId }: StartStudyModalProps) { return; } - joinStudy( - { - ...form, - memberId, - githubLink: githubLink.trim() || undefined, - blogOrSnsLink: blogOrSnsLink.trim() || undefined, - }, - { - onSuccess: () => { - alert('스터디 신청이 완료되었습니다!'); - router.refresh(); - }, - onError: () => { - alert('스터디 신청 중 오류가 발생했습니다. 다시 시도해 주세요.'); - }, - }, - ); + joinStudy({ + ...form, + memberId, + githubLink: githubLink.trim() || undefined, + blogOrSnsLink: blogOrSnsLink.trim() || undefined, + }); }; return ( diff --git a/src/shared/lib/server-cookie.ts b/src/shared/lib/server-cookie.ts index 1f422428..233c8bf1 100644 --- a/src/shared/lib/server-cookie.ts +++ b/src/shared/lib/server-cookie.ts @@ -9,11 +9,11 @@ export const getServerCookie = async ( return value ?? undefined; }; -export const setServerCookie = async( +export const setServerCookie = async ( name: string, value: string, options: { path?: string } = {}, ): Promise => { const cookieStore = await cookies(); cookieStore.set(name, value, { path: '/', ...options }); -}; \ No newline at end of file +}; diff --git a/src/shared/tanstack-query/api-error.ts b/src/shared/tanstack-query/api-error.ts new file mode 100644 index 00000000..0fcd1f41 --- /dev/null +++ b/src/shared/tanstack-query/api-error.ts @@ -0,0 +1,40 @@ +class ApiError extends Error { + name = 'ApiError'; + statusCode: number; + errorCode: string; + errorName: string; + message: string; + + constructor({ + statusCode, + errorCode, + errorName, + message, + }: { + statusCode: number; + errorCode: string; + errorName: string; + message: string; + }) { + super(message); + this.statusCode = statusCode; + this.errorCode = errorCode; + this.errorName = errorName; + this.message = message; + } +} + +// API 에러인지 확인하는 함수 +const isApiError = (error: unknown): error is ApiError => { + return ( + error instanceof ApiError || + (typeof error === 'object' && + error !== null && + 'statusCode' in error && + 'errorCode' in error && + 'errorName' in error && + 'message' in error) + ); +}; + +export { ApiError, isApiError }; diff --git a/src/shared/tanstack-query/axios.all.ts b/src/shared/tanstack-query/axios.all.ts new file mode 100644 index 00000000..164cfc7a --- /dev/null +++ b/src/shared/tanstack-query/axios.all.ts @@ -0,0 +1,26 @@ +import axios, { isAxiosError } from 'axios'; +import { ApiError, isApiError } from './api-error'; + +// * 인증하지 않는 axios 인스턴스 +export const axiosAllInstance = axios.create({ + baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +axiosAllInstance.interceptors.response.use( + (response) => response, + (error) => { + if ( + error.response && + isAxiosError(error) && + isApiError(error.response.data) + ) { + throw new ApiError(error.response.data); + } + + return Promise.reject(error); + }, +); diff --git a/src/shared/tanstack-query/axios.client.ts b/src/shared/tanstack-query/axios.client.ts new file mode 100644 index 00000000..26a5f647 --- /dev/null +++ b/src/shared/tanstack-query/axios.client.ts @@ -0,0 +1,59 @@ +import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios'; +import { ApiError, isApiError } from './api-error'; +import { getCookie } from './cookie'; + +// * 인증이 필요한 client-side axios 인스턴스 + +// json 요청 +export const axiosClientInstance = axios.create({ + baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, +}); + +// multipart 요청용 +export const axiosClientInstanceForMultipart = axios.create({ + baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`, + timeout: 10000, + headers: { + // JS에서 formData 를 넘길땐 Content-Type 생략해야 자동으로 multipart/form-data + boundary 설정됨 + }, +}); + +const onRequestClient = (config: InternalAxiosRequestConfig) => { + const accessToken = getCookie('accessToken'); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +}; + +axiosClientInstance.interceptors.request.use(onRequestClient); +axiosClientInstanceForMultipart.interceptors.request.use(onRequestClient); + +const onResponseErrorClient = async (error: unknown) => { + if (isAxiosError(error) && error.response) { + const errorResponseBody = error.response.data; + + if (isApiError(errorResponseBody)) { + const accessToken = getCookie('accessToken'); + + // 유효하지 않은 accessToken인 경우, 재발급 + if (accessToken && errorResponseBody.errorCode === 'AUTH001') { + // refresh accessToken + } + + throw new ApiError(errorResponseBody); + } + } +}; + +axiosClientInstance.interceptors.response.use( + (config) => config, + onResponseErrorClient, +); diff --git a/src/shared/tanstack-query/axios.server.ts b/src/shared/tanstack-query/axios.server.ts new file mode 100644 index 00000000..5351413a --- /dev/null +++ b/src/shared/tanstack-query/axios.server.ts @@ -0,0 +1,48 @@ +import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios'; +import { ApiError, isApiError } from './api-error'; +import { getServerCookie } from '../lib/server-cookie'; + +// * 인증이 필요한 server-side axios 인스턴스 + +// json 요청 +export const axiosServerInstance = axios.create({ + baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +const onRequestServer = async (config: InternalAxiosRequestConfig) => { + const accessToken = await getServerCookie('accessToken'); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +}; + +axiosServerInstance.interceptors.request.use(onRequestServer); + +const onResponseErrorServer = async (error: unknown) => { + if (isAxiosError(error) && error.response) { + const errorResponseBody = error.response.data; + + if (isApiError(errorResponseBody)) { + const accessToken = await getServerCookie('accessToken'); + + // 유효하지 않은 accessToken인 경우, 재발급 + if (accessToken && errorResponseBody.errorCode === 'AUTH001') { + // refresh accessToken + } + + throw new ApiError(errorResponseBody); + } + } +}; + +axiosServerInstance.interceptors.response.use( + (config) => config, + onResponseErrorServer, +); diff --git a/src/shared/tanstack-query/axios.ts b/src/shared/tanstack-query/axios.ts deleted file mode 100644 index d17b4a56..00000000 --- a/src/shared/tanstack-query/axios.ts +++ /dev/null @@ -1,91 +0,0 @@ -import axios from 'axios'; -import { getCookie, setCookie } from './cookie'; - -// json 요청용 -export const axiosInstance = axios.create({ - baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, - withCredentials: true, -}); - -// multipart 요청용 -export const axiosInstanceForMultipart = axios.create({ - baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`, - timeout: 10000, - headers: { - // JS에서 formData 를 넘길땐 Content-Type 생략해야 자동으로 multipart/form-data + boundary 설정됨 - }, -}); - -/* - accessToken 은 쿠키에 저장 - refreshToken 은 HttpOnly 쿠키로 JS에서 접근 불가, 백엔드 서버와 쿠키로 통신 -*/ - -// multipart 요청 로깅용 -axiosInstanceForMultipart.interceptors.request.use( - (config) => { - return config; - }, - (error) => Promise.reject(error), -); - -axiosInstance.interceptors.request.use( - (config) => { - const accessToken = getCookie('accessToken'); - if (accessToken) { - config.headers.Authorization = `Bearer ${accessToken}`; - } - // 로컬 테스트에서 사용시 주석 제거 - // console.log("------------------------") - // console.log("✅ 요청주소", config.url); - // console.log("✅ 요청 Bearer", config.headers.Authorization); - // console.log("✅ 요청내용", config); - - return config; - }, - (error) => Promise.reject(error), -); - -axiosInstance.interceptors.response.use( - (response) => { - // 로컬 테스트에서 사용시 주석 제거 - // console.log('------------------------'); - // console.log('✅ 응답주소', response.request.responseURL); - // console.log('✅ 응답로그', response); - - return response; - }, - - async (error) => { - console.log('에러 확인', error); - console.log('에러 상태코드:', error.response?.status); - - const originalRequest = error.config; - if (error.response?.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - try { - const refreshApi = axios.create({ - baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`, - }); - const res = await refreshApi.get('/auth/access-token/refresh'); - const newAccessToken = res.data.accessToken; - - if (newAccessToken) { - setCookie('accessToken', newAccessToken); - originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - - return axiosInstance(originalRequest); - } - } catch (err) { - // 로그인 페이지 리다이렉트 등 처리 - return Promise.reject(err); - } - } - - return Promise.reject(error); - }, -); diff --git a/src/shared/ui/toast/index.ts b/src/shared/ui/toast/index.ts new file mode 100644 index 00000000..6b924b29 --- /dev/null +++ b/src/shared/ui/toast/index.ts @@ -0,0 +1,20 @@ +import { toast } from 'sonner'; + +const openToast = ({ + type = 'info', + text, +}: { + type?: 'success' | 'danger' | 'info'; + text: string; +}) => { + if (type === 'success') { + return toast.success(text); + } + if (type === 'danger') { + return toast.warning(text); + } + + return toast.info(text); +}; + +export { openToast }; diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx index 9709d142..572598cd 100644 --- a/src/widgets/home/sidebar.tsx +++ b/src/widgets/home/sidebar.tsx @@ -1,3 +1,4 @@ +import { redirect } from 'next/navigation'; import { getUserProfile } from '@/entities/user/api/get-user-profile'; import MyProfileCard from '@/features/study/ui/my-profile-card'; import StartStudyModal from '@/features/study/ui/start-study-modal'; @@ -8,6 +9,10 @@ import TodoList from '@/widgets/home/todo-list'; export default async function Sidebar() { const memberId = await getLoginUserId(); + if (!memberId) { + redirect('/login'); + } + const userProfile = await getUserProfile(memberId); return ( diff --git a/src/widgets/my-page/sidebar.tsx b/src/widgets/my-page/sidebar.tsx index 2e9d9cb1..e3f5fbf7 100644 --- a/src/widgets/my-page/sidebar.tsx +++ b/src/widgets/my-page/sidebar.tsx @@ -1,38 +1,16 @@ 'use client'; -import { sendGTMEvent } from '@next/third-parties/google'; -import { useQueryClient } from '@tanstack/react-query'; import { usePathname, useRouter } from 'next/navigation'; -import { logout } from '@/features/auth/api/auth'; -import { hashValue } from '@/shared/lib/hash'; +import { useLogoutMutation } from '@/features/auth/model/use-auth-mutation'; import { cn } from '@/shared/shadcn/lib/utils'; -import { deleteCookie, getCookie } from '@/shared/tanstack-query/cookie'; export default function Sidebar() { const router = useRouter(); - const queryClient = useQueryClient(); const pathname = usePathname(); + const { mutateAsync: logout } = useLogoutMutation(); const handleLogout = async () => { - try { - await logout(); - - const memberId = getCookie('memberId'); - sendGTMEvent({ - event: 'custom_member_logout', - dl_timestamp: new Date().toISOString(), - dl_member_id: hashValue(memberId), - }); - - deleteCookie('accessToken'); - deleteCookie('memberId'); - - queryClient.clear(); - router.push('/'); - router.refresh(); - } catch (error) { - console.error('로그아웃 실패:', error); - } + await logout(); }; return ( diff --git a/yarn.lock b/yarn.lock index 4a6df8da..ffe7cabc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6852,6 +6852,11 @@ snake-case@^3.0.4: dot-case "^3.0.4" tslib "^2.0.3" +sonner@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/sonner/-/sonner-2.0.6.tgz#623b73faec55229d63ec35226d42021f2119bfa7" + integrity sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q== + source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.0, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" @@ -6906,16 +6911,8 @@ strict-event-emitter@^0.5.1: resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93" integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7017,14 +7014,8 @@ stringify-object@^5.0.0: is-obj "^3.0.0" is-regexp "^3.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + name strip-ansi-cjs version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7644,7 +7635,8 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -7662,15 +7654,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"