diff --git a/eslint.config.mjs b/eslint.config.mjs index 3787778..2dc3211 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,6 +34,7 @@ export default [ { rules: { 'prettier/prettier': 'warn', + 'no-console': ['error', { allow: ['warn', 'error'] }], }, }, tsConfig, diff --git a/src/App.tsx b/src/App.tsx index d4db07f..6edceb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,19 @@ -import './App.css'; +import '@/App.css'; +import { useMemo } from 'react'; import { RouterProvider } from 'react-router-dom'; +import { DeviceTokenProvider } from '@/providers/deviceTokenProvider'; +import { alarmKeys } from '@/queryKey/queryKey'; import router from '@/routes/routes'; function App() { - return ; + const refetchKeys = useMemo(() => [alarmKeys._def], []); + return ( + + + + ); } export default App; diff --git a/src/api/axiosInstance.ts b/src/api/axiosInstance.ts index 14b53f7..c621bd6 100644 --- a/src/api/axiosInstance.ts +++ b/src/api/axiosInstance.ts @@ -34,30 +34,26 @@ axiosInstance.interceptors.response.use( const refreshResponse = await refresh(); if (refreshResponse.code === '200') { - console.log('refreshToken이 재발급 되었습니다'); isRedirecting = false; return axiosInstance(error.config); } } catch (errors) { if (axios.isAxiosError(errors)) { - const refreshError = error as AxiosError; + const refreshError = errors as AxiosError; if (refreshError.response?.data.message === 'The token is null.') { - console.log('refreshToken이 없습니다. 로그인 페이지로 이동합니다.'); + console.error('refreshToken이 없습니다. 로그인 페이지로 이동합니다.'); + void logout(); } else if (refreshError.response?.data.message === 'The token is invalid.') { - console.log('refreshToken이 만료되었습니다. 로그인 페이지로 이동합니다.'); - logout(); + console.error('refreshToken이 만료되었습니다. 로그인 페이지로 이동합니다.'); + void logout(); } else { - if (refreshError.response?.data.message === 'Incorrect password.') { - alert('Your email or password is incorrect.'); - } else { - console.log('알 수 없는 오류가 발생했습니다', errors); - logout(); - } + console.error('알 수 없는 오류가 발생했습니다', errors); + void logout(); } } else { - console.log('알 수 없는 오류가 발생했습니다', errors); - logout(); + console.error('알 수 없는 오류가 발생했습니다', errors); + void logout(); } return Promise.reject(errors); diff --git a/src/components/auth/commonAuthInput.tsx b/src/components/auth/commonAuthInput.tsx index 1392e26..02bb98a 100644 --- a/src/components/auth/commonAuthInput.tsx +++ b/src/components/auth/commonAuthInput.tsx @@ -1,8 +1,6 @@ import type { InputHTMLAttributes } from 'react'; import React from 'react'; -import formatInputNumber from '@/utils/formatPhoneNumber'; - import Button from '@/components/common/Button'; import AlertCircle from '@/assets/icons/alert-circle_Fill.svg?react'; @@ -58,21 +56,6 @@ const CommonAuthInput = React.forwardRef { - const rawValue = e.target.value; - const formatted = type === 'phoneNum' ? formatInputNumber(rawValue) : rawValue; - - // 외부에서 넘긴 onChange 핸들러에 적용된 값 전달 - if (rest.onChange) { - rest.onChange({ - ...e, - target: { - ...e.target, - value: formatted, - }, - }); - } - }} {...rest} /> {short &&
} @@ -96,7 +79,7 @@ const CommonAuthInput = React.forwardRef )} - {error &&
{errorMessage}
} + {error &&
{errorMessage}
} {error && }
); diff --git a/src/components/common/PasswordEdit.tsx b/src/components/common/PasswordEdit.tsx index b1c53ce..ff16cab 100644 --- a/src/components/common/PasswordEdit.tsx +++ b/src/components/common/PasswordEdit.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import type { AxiosError } from 'axios'; import { z } from 'zod'; import { useAccount } from '@/hooks/auth/useAccount'; @@ -28,16 +29,7 @@ export default function PasswordEditSection() { setIsEditing(false); }; - const { mutate: changePw, isPending } = useChangePassword({ - onSuccess: () => { - alert('비밀번호가 변경되었습니다.'); - handleCancel(); - }, - onError: (err) => { - const msg = (err as any)?.response?.data?.message ?? '비밀번호 변경에 실패했습니다.'; - alert(msg); - }, - }); + const { mutate: changePw, isPending } = useChangePassword(); // 제출 const handleSubmit = () => { @@ -58,10 +50,22 @@ export default function PasswordEditSection() { if (Object.keys(nextErrors).length > 0) return; // 제출 - changePw({ - currentPassword: currentPw, - newPassword: newPw, - }); + changePw( + { + currentPassword: currentPw, + newPassword: newPw, + }, + { + onSuccess: () => { + alert('비밀번호가 변경되었습니다.'); + handleCancel(); + }, + onError: (err: AxiosError) => { + const msg = (err as any)?.response?.data?.message ?? '비밀번호 변경에 실패했습니다.'; + alert(msg); + }, + }, + ); }; // 공통 인풋 스타일 diff --git a/src/components/common/modal.tsx b/src/components/common/modal.tsx index 8edcf8c..6cbab53 100644 --- a/src/components/common/modal.tsx +++ b/src/components/common/modal.tsx @@ -33,7 +33,7 @@ export default function Modal({ isOpen = true, title, children, onClose, positio
{title}
-
{children}
+
{children}
diff --git a/src/components/dateCourse/dateCourse.tsx b/src/components/dateCourse/dateCourse.tsx index cc2b5f5..f144982 100644 --- a/src/components/dateCourse/dateCourse.tsx +++ b/src/components/dateCourse/dateCourse.tsx @@ -20,7 +20,6 @@ function DateCourse({ defaultOpen = false }: { defaultOpen?: boolean }) { } else { setIsBookmarked(!isBookmarked); } - // console.log('북마크 해제'); }; useEffect(() => { diff --git a/src/components/dateCourse/dateCourseSearchFilterOption.tsx b/src/components/dateCourse/dateCourseSearchFilterOption.tsx index 38b8c80..44eab4b 100644 --- a/src/components/dateCourse/dateCourseSearchFilterOption.tsx +++ b/src/components/dateCourse/dateCourseSearchFilterOption.tsx @@ -88,7 +88,7 @@ export default function DateCourseSearchFilterOption({ options, type, value, onC mode="search" onSearchClick={handleSearch} placeholder="ex: 서울시 강남구" - className="w-full" + className="!w-full min-w-full" value={inputValue} onChange={handleInputChange} /> diff --git a/src/components/home/dateRecommend.tsx b/src/components/home/dateRecommend.tsx index fdb13e5..754eb34 100644 --- a/src/components/home/dateRecommend.tsx +++ b/src/components/home/dateRecommend.tsx @@ -99,7 +99,7 @@ function DateRecommend() {
{/* 상단 텍스트 */}
-
이번 주 {forecastData?.result?.region?.regionName ?? '지역'} 데이트 추천
+
이번 주 {forecastData?.result?.region?.regionName ?? '지역'} 데이트 추천
- ))} -
- - -
+ > + {tab} 설정 + + ))} + -
- {activeTab === '알람' && } - {activeTab === '멤버십' && } - {activeTab === '정보' && } + +
+ +
+ {activeTab === '알람' && } + {activeTab === '멤버십' && } + {activeTab === '정보' && } +
); diff --git a/src/components/modal/dateCourseSearchFilterModal.tsx b/src/components/modal/dateCourseSearchFilterModal.tsx index 17f7163..c98fd77 100644 --- a/src/components/modal/dateCourseSearchFilterModal.tsx +++ b/src/components/modal/dateCourseSearchFilterModal.tsx @@ -43,7 +43,6 @@ export default function DateCourseSearchFilterModal({ onClose }: TDateCourseSear const [errorMessages, setErrorMessages] = useState(Array(7).fill('')); const handleSearch = () => { - console.log('선택된 필터:', answers); onClose(); }; diff --git a/src/components/modal/deleteBookmarkModal.tsx b/src/components/modal/deleteBookmarkModal.tsx index ed449cf..669823a 100644 --- a/src/components/modal/deleteBookmarkModal.tsx +++ b/src/components/modal/deleteBookmarkModal.tsx @@ -15,7 +15,6 @@ function DeleteBookmarkModal({ onClose, isOpen, changeState }: TDeleteBookmarkMo }, [isOpen]); const handleDelete = () => { - // Logic to delete the bookmarked date course changeState(false); onClose(); }; diff --git a/src/components/modal/regionModal.tsx b/src/components/modal/regionModal.tsx index cf51cb8..b8771ca 100644 --- a/src/components/modal/regionModal.tsx +++ b/src/components/modal/regionModal.tsx @@ -6,6 +6,9 @@ import { useUserRegion } from '@/hooks/home/useUserRegion'; import EditableInputBox from '@/components/common/EditableInputBox'; import Modal from '@/components/common/modal'; +import { queryClient } from '@/api/queryClient'; +import { homeKeys } from '@/queryKey/queryKey'; + interface IRegionModalProps { onClose: () => void; } @@ -23,6 +26,7 @@ function RegionModal({ onClose }: IRegionModalProps) { }, { onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: homeKeys.userRegion().queryKey }); onClose(); }, }, diff --git a/src/components/settingTab/AlarmSetting.tsx b/src/components/settingTab/AlarmSetting.tsx index f650568..963b3d8 100644 --- a/src/components/settingTab/AlarmSetting.tsx +++ b/src/components/settingTab/AlarmSetting.tsx @@ -4,6 +4,9 @@ import { useGetAlarmSettings, usePatchAlarmSettings } from '@/hooks/settingAlarm import ToggleSwitch from '@/components/common/ToggleSwitch'; +import { queryClient } from '@/api/queryClient'; +import { alarmKeys } from '@/queryKey/queryKey'; + type TAlarmType = 'email' | 'push' | 'sms'; interface IAlarmSettingState { @@ -36,22 +39,24 @@ export default function AlarmSetting() { // 토글 핸들러 const handleToggle = (key: TAlarmType) => { - const prev = alarmSetting; - const next = { ...prev, [key]: !prev[key] }; - setAlarmSetting(next); - - patchAlarm( - { - emailAlarm: next.email, - pushAlarm: next.push, - smsAlarm: next.sms, - }, - { - onError: () => setAlarmSetting(prev), - }, - ); + setAlarmSetting((prev) => { + const next = { ...prev, [key]: !prev[key] }; + patchAlarm( + { + emailAlarm: next.email, + pushAlarm: next.push, + smsAlarm: next.sms, + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: alarmKeys.alarmSettings().queryKey }); + }, + onError: () => setAlarmSetting(prev), + }, + ); + return next; + }); }; - const items: { label: string; key: TAlarmType }[] = [ { label: 'Email 알람', key: 'email' }, { label: '푸쉬 알람', key: 'push' }, @@ -59,9 +64,9 @@ export default function AlarmSetting() { ]; return ( -
+
{items.map(({ label, key }) => ( -
+

{label}

handleToggle(key)} onLabel="ON" offLabel="OFF" />
diff --git a/src/components/settingTab/InfoSetting.tsx b/src/components/settingTab/InfoSetting.tsx index 3d1d182..7a43a41 100644 --- a/src/components/settingTab/InfoSetting.tsx +++ b/src/components/settingTab/InfoSetting.tsx @@ -1,20 +1,18 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; import { TERMS_URL } from '@/constants/policies'; -import { QUERY_KEYS, useAccount } from '@/hooks/auth/useAccount'; +import { useAccount } from '@/hooks/auth/useAccount'; import EditableInputBox from '../common/EditableInputBox'; import PasswordEditSection from '../common/PasswordEdit'; +import { queryClient } from '@/api/queryClient'; import ChevronForward from '@/assets/icons/default_arrows/chevron_forward.svg?react'; - -const getApiErrorMessage = (err: any, fallback: string) => err?.response?.data?.message ?? (err?.response?.status === 401 ? '로그인이 필요합니다.' : fallback); +import { memberKeys } from '@/queryKey/queryKey'; export default function InfoSetting() { - const qc = useQueryClient(); const { useGetMemberInfo, useChangeNickname, useResetPreferences } = useAccount(); const { data: memberData, isLoading: infoLoading, isError: infoError } = useGetMemberInfo(); @@ -32,40 +30,32 @@ export default function InfoSetting() { } }, [apiNickname]); - const { mutate: changeNickname, isPending: nickPending } = useChangeNickname({ - onSuccess: (res) => { - if (res?.isSuccess) { - const next = res.result.username; - setNickname(next); - setInitialNickname(next); - localStorage.setItem('nickname', next); - - qc.invalidateQueries({ queryKey: QUERY_KEYS.memberInfo }); - qc.invalidateQueries({ queryKey: QUERY_KEYS.memberGrade }); - qc.setQueryData(['userGrade'], (old: any) => (old ? { ...old, result: { ...old.result, username: next } } : old)); - } else { - alert(res?.message ?? '닉네임 변경에 실패했습니다.'); - } - }, - onError: (err: any) => alert(getApiErrorMessage(err, '닉네임 변경에 실패했습니다.')), - }); - - const { mutate: resetPref, isPending: resetPending } = useResetPreferences({ - onSuccess: (res) => { - if (res?.isSuccess) { - alert('취향 데이터가 초기화되었습니다.'); - } else { - alert(res?.message ?? '초기화에 실패했습니다.'); - } - }, - onError: (err: any) => alert(getApiErrorMessage(err, '초기화에 실패했습니다.')), - }); + const { mutate: changeNickname, isPending: nickPending } = useChangeNickname(); + + const { mutate: resetPref, isPending: resetPending } = useResetPreferences(); const handleSubmitNickname = () => { const trimmed = nickname.trim(); if (!trimmed) return alert('닉네임을 입력해 주세요.'); if (trimmed === initialNickname || nickPending) return; - changeNickname({ username: trimmed }); + changeNickname( + { username: trimmed }, + { + onSuccess: (res) => { + if (res?.isSuccess) { + const next = res.result.username; + setNickname(next); + setInitialNickname(next); + localStorage.setItem('nickname', next); + + queryClient.invalidateQueries({ queryKey: memberKeys._def }); + } else { + alert(res?.message ?? '닉네임 변경에 실패했습니다.'); + } + }, + onError: () => alert('닉네임 변경에 실패했습니다.'), + }, + ); }; const handleCancelNickname = () => { @@ -75,11 +65,20 @@ export default function InfoSetting() { const handleResetPreferences = () => { if (resetPending) return; if (!confirm('정말 초기화할까요? 되돌릴 수 없습니다.')) return; - resetPref(); + resetPref(undefined, { + onSuccess: (res: any) => { + if (res?.isSuccess) { + alert('취향 데이터가 초기화되었습니다.'); + } else { + alert(res?.message ?? '초기화에 실패했습니다.'); + } + }, + onError: () => alert('초기화에 실패했습니다.'), + }); }; return ( -
+
{/* 닉네임 */} {/* 이메일 */} - {}} - className="pointer-events-none" - placeholder="이메일" - /> +
+

{'이메일'}

+
+ {infoLoading ? '불러오는 중' : infoError ? '' : email} +
+
{/* 비밀번호 변경 */} diff --git a/src/components/settingTab/MembershipSetting.tsx b/src/components/settingTab/MembershipSetting.tsx index 7d2fa0d..3d68009 100644 --- a/src/components/settingTab/MembershipSetting.tsx +++ b/src/components/settingTab/MembershipSetting.tsx @@ -47,14 +47,14 @@ export default function MembershipSetting() { }; return ( -
+

현재 등급은 {gradeText} 입니다.

다음 결제 예정일 : 2025.06.14

-
+

자동 결제

diff --git a/src/components/settingTab/mobileSettingTab.tsx b/src/components/settingTab/mobileSettingTab.tsx new file mode 100644 index 0000000..63acdd3 --- /dev/null +++ b/src/components/settingTab/mobileSettingTab.tsx @@ -0,0 +1,33 @@ +import LogoutSvg from '@/assets/icons/Logout_Blank.svg?react'; + +type TMobileSettingTab = { + setActiveTab: (activeTab: '알람' | '멤버십' | '정보') => void; + activeTab: '알람' | '멤버십' | '정보'; + handleLogout: () => void; + logoutPending: boolean; +}; +export default function MobileSettingTab({ setActiveTab, activeTab, handleLogout, logoutPending }: TMobileSettingTab) { + return ( +
+ {['알람', '멤버십', '정보'].map((tab) => ( + + ))} + + +
+ ); +} diff --git a/src/firebase/firebase.ts b/src/firebase/firebase.ts index 0887824..d202343 100644 --- a/src/firebase/firebase.ts +++ b/src/firebase/firebase.ts @@ -1,6 +1,7 @@ // src/firebase/firebase.ts import { initializeApp } from 'firebase/app'; -import { getMessaging, getToken } from 'firebase/messaging'; +import type { Messaging } from 'firebase/messaging'; +import { deleteToken, getMessaging, getToken, isSupported } from 'firebase/messaging'; const firebaseConfig = { apiKey: 'AIzaSyAjZqK2lhCOeX_P2Sf-_2IGEFlORchcO5w', @@ -13,22 +14,39 @@ const firebaseConfig = { }; const app = initializeApp(firebaseConfig); -const messaging = getMessaging(app); +export let messaging: Messaging | null = null; +async function ensureMessaging() { + if (!(await isSupported())) return null; + if (!messaging) messaging = getMessaging(app); + return messaging; +} +(async () => { + if (await isSupported()) { + messaging = getMessaging(app); + } +})(); + +export async function generateToken(): Promise { + const m = await ensureMessaging(); + if (!m) return null; + + // 권한 요청 (이미 허용/거부된 상태면 브라우저가 적절히 동작) + if ('Notification' in window && Notification.permission !== 'granted') { + const perm = await Notification.requestPermission(); + if (perm !== 'granted') return null; + } -export const generateToken = async () => { try { - const token = await getToken(messaging, { + const token = await getToken(m, { vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY, + serviceWorkerRegistration: await navigator.serviceWorker.ready, }); - if (!token) { - console.warn('FCM 토큰 생성에 실패했습니다. 알림 권한을 확인해주세요.'); - } - return token; - } catch (error) { - console.error('FCM 토큰 생성 중 오류 발생:', error); + return token ?? null; + } catch (e) { + console.error('FCM getToken 실패:', e); return null; } -}; +} export const registerServiceWorker = async () => { try { @@ -39,3 +57,14 @@ export const registerServiceWorker = async () => { console.error('Service Worker registration failed:', err); } }; + +export async function deleteFcmToken(): Promise { + const m = await ensureMessaging(); + if (!m) return false; + try { + return await deleteToken(m); + } catch (e) { + console.error('FCM deleteToken 실패:', e); + return false; + } +} diff --git a/src/hooks/alarm/useDeviceToken.ts b/src/hooks/alarm/useDeviceToken.ts deleted file mode 100644 index 2fd48c0..0000000 --- a/src/hooks/alarm/useDeviceToken.ts +++ /dev/null @@ -1,39 +0,0 @@ -// // src/hooks/alarm/useDeviceToken.ts -// import { useEffect } from 'react'; -// import { isSupported } from 'firebase/messaging'; - -// import { postDeviceToken } from '@/api/alarm/alarm'; // 서버에 FCM 토큰 전송하는 API 함수 -// import { generateToken, registerServiceWorker } from '@/firebase/firebase'; - -// export const useDeviceToken = () => { -// useEffect(() => { -// const setupFCM = async () => { -// if (!(await isSupported())) { -// console.warn('FCM은 현재 브라우저에서 지원되지 않습니다.'); -// return; -// } - -// await registerServiceWorker(); -// const token = await generateToken(); - -// if (token) { -// try { -// await postDeviceToken({ deviceToken: token }); // 서버에 전송 -// } catch (err) { -// console.error('디바이스 토큰 서버 전송 실패:', err); -// } -// } -// }; - -// const handleClick = () => { -// setupFCM(); -// window.removeEventListener('click', handleClick); -// }; - -// window.addEventListener('click', handleClick); - -// return () => { -// window.removeEventListener('click', handleClick); -// }; -// }, []); -// }; diff --git a/src/hooks/alarm/usePostDeviceToken.ts b/src/hooks/alarm/usePostDeviceToken.ts new file mode 100644 index 0000000..94509c4 --- /dev/null +++ b/src/hooks/alarm/usePostDeviceToken.ts @@ -0,0 +1,8 @@ +import { useCoreMutation } from '../customQuery'; + +import { postDeviceToken } from '@/api/alarm/alarm'; + +export function useFirebase() { + const postDeviceTokenMutation = useCoreMutation(postDeviceToken); + return { postDeviceTokenMutation }; +} diff --git a/src/hooks/auth/useAccount.ts b/src/hooks/auth/useAccount.ts index d7312bd..8fc3534 100644 --- a/src/hooks/auth/useAccount.ts +++ b/src/hooks/auth/useAccount.ts @@ -1,54 +1,38 @@ -import type { - TChangeNicknameMutationOptions, - TChangeNicknameMutationResult, - TChangeNicknamePayload, - TChangeNicknameResponse, - TChangePasswordMutationOptions, - TChangePasswordMutationResult, - TChangePasswordPayload, -} from '@/types/auth/account'; -import type { TUseMutationCustomOptions } from '@/types/common/common'; -import type { TResetPreferencesResponse } from '@/types/dates/preferences'; - import { useCoreMutation, useCoreQuery } from '@/hooks/customQuery'; import { changeNickname, changePassword, deleteMember, getMemberGrade, getMemberInfo } from '@/api/auth/account'; import { resetPreferences } from '@/api/dates/preferences'; - -export const QUERY_KEYS = { - memberInfo: ['memberInfo'] as const, - memberGrade: ['memberGrade'] as const, -} as const; +import { memberKeys } from '@/queryKey/queryKey'; export function useAccount() { // 비밀번호 변경 - function useChangePassword(options?: TChangePasswordMutationOptions): TChangePasswordMutationResult { - return useCoreMutation(changePassword, options); + function useChangePassword() { + return useCoreMutation(changePassword); } // 닉네임 변경 - function useChangeNickname(options?: TChangeNicknameMutationOptions): TChangeNicknameMutationResult { - return useCoreMutation(changeNickname, options); + function useChangeNickname() { + return useCoreMutation(changeNickname); } // 회원 탈퇴 - function useDeleteMember(options?: TUseMutationCustomOptions) { - return useCoreMutation(deleteMember, options); + function useDeleteMember() { + return useCoreMutation(deleteMember); } // 사용자 정보 조회 function useGetMemberInfo() { - return useCoreQuery(QUERY_KEYS.memberInfo, getMemberInfo); + return useCoreQuery(memberKeys.memberInfo.queryKey, getMemberInfo); } // 사용자 등급 조회 function useGetMemberGrade() { - return useCoreQuery(QUERY_KEYS.memberGrade, getMemberGrade); + return useCoreQuery(memberKeys.memberGrade.queryKey, getMemberGrade); } // 취향 데이터 초기화 - function useResetPreferences(options?: TUseMutationCustomOptions) { - return useCoreMutation(resetPreferences, options); + function useResetPreferences() { + return useCoreMutation(resetPreferences); } return { useChangePassword, useChangeNickname, useDeleteMember, useGetMemberInfo, useGetMemberGrade, useResetPreferences }; diff --git a/src/hooks/customQuery.ts b/src/hooks/customQuery.ts index 6df4461..2a05e5c 100644 --- a/src/hooks/customQuery.ts +++ b/src/hooks/customQuery.ts @@ -1,9 +1,10 @@ import { type MutationFunction, type QueryFunction, type QueryKey, useMutation, useQuery, type UseQueryResult } from '@tanstack/react-query'; import type { AxiosError } from 'axios'; -import { toast } from 'sonner'; import type { TUseMutationCustomOptions, TUseQueryCustomOptions } from '@/types/common/common'; +import { queryClient } from '@/api/queryClient'; + export function useCoreQuery( keyName: QueryKey, query: QueryFunction, @@ -13,17 +14,59 @@ export function useCoreQuery( queryKey: keyName, queryFn: query, ...options, - staleTime: options?.staleTime ?? 1000 * 60 * 5, }); } -//options 타입을 제네릭 변경 -export function useCoreMutation(mutation: MutationFunction, options?: TUseMutationCustomOptions) { - return useMutation({ +export function useCoreMutation< + TData, + TVariables, + TError = AxiosError<{ message?: string }>, // 필요 시 TResponseError 등으로 대체 + TContext extends { prevData?: unknown } = { prevData?: unknown }, + TCache = unknown, +>(mutation: MutationFunction, options?: TUseMutationCustomOptions) { + const { + optimisticUpdate, // { key: QueryKey; updateFn: (old: TCache | undefined, vars: TVariables) => TCache } + invalidateKeys, + userOnError, + userOnSuccess, + ...rest // retry, gcTime 등 표준 옵션(UseMutationOptions 호환) + } = options ?? {}; + + return useMutation({ mutationFn: mutation, - onError: (error) => { - toast.error(error.response?.data.message || 'An error occurred.'); + + // 중요: onMutate는 반드시 TContext | undefined를 반환해야 함 + onMutate: async (vars): Promise => { + if (!optimisticUpdate) return undefined; + + await queryClient.cancelQueries({ queryKey: optimisticUpdate.key }); + + const prevData = queryClient.getQueryData(optimisticUpdate.key); + + // 캐시 타입 안전하게 업데이트 + queryClient.setQueryData(optimisticUpdate.key, (old) => optimisticUpdate.updateFn(old as TCache | undefined, vars)); + + // prevData를 컨텍스트로 보관 + return { prevData } as TContext; }, - ...options, + + onError: (error, vars, ctx) => { + // 롤백 + if (optimisticUpdate && ctx?.prevData !== undefined) { + queryClient.setQueryData(optimisticUpdate.key, ctx.prevData as TCache); + } + userOnError?.(error, vars, ctx); + }, + + onSuccess: async (data, vars, ctx) => { + // 꼭 invalidate가 필요한 키만 + if (invalidateKeys?.length) { + await Promise.all(invalidateKeys.map((key) => queryClient.invalidateQueries({ queryKey: key }))); + } + userOnSuccess?.(data, vars, ctx); + }, + + // 나머지 표준 옵션 주입 + ...rest, }); } diff --git a/src/hooks/home/useDateCourseStates.ts b/src/hooks/home/useDateCourseStates.ts index 39e50c2..73c7a63 100644 --- a/src/hooks/home/useDateCourseStates.ts +++ b/src/hooks/home/useDateCourseStates.ts @@ -1,11 +1,11 @@ import { useCoreQuery } from '@/hooks/customQuery'; import { getDateCourseSavedCount } from '@/api/home/dateCourse'; -import { HomeKeys } from '@/queryKey/queryKey'; +import { homeKeys } from '@/queryKey/queryKey'; // 데이트 코스 저장 횟수 훅 export const useDateCourseSavedCount = () => { - return useCoreQuery(HomeKeys.dateCourseSave().queryKey, getDateCourseSavedCount, { + return useCoreQuery(homeKeys.dateCourseSave().queryKey, getDateCourseSavedCount, { gcTime: 15 * 60 * 1000, // 15분 retry: 3, }); diff --git a/src/hooks/home/useDatePlaceStates.ts b/src/hooks/home/useDatePlaceStates.ts index 35c60df..9f865c0 100644 --- a/src/hooks/home/useDatePlaceStates.ts +++ b/src/hooks/home/useDatePlaceStates.ts @@ -1,10 +1,10 @@ -import { useCoreQuery } from '../customQuery'; +import { useCoreQuery } from '@/hooks/customQuery'; import { getMonthlyDatePlaceStates } from '@/api/home/dateTimes'; -import { HomeKeys } from '@/queryKey/queryKey'; +import { homeKeys } from '@/queryKey/queryKey'; export const useMonthlyPlaceStates = () => { - return useCoreQuery(HomeKeys.monthlyPlaceStates().queryKey, () => getMonthlyDatePlaceStates(), { + return useCoreQuery(homeKeys.monthlyPlaceStates.queryKey, getMonthlyDatePlaceStates, { gcTime: 15 * 60 * 1000, retry: 3, }); diff --git a/src/hooks/home/useDateTimes.ts b/src/hooks/home/useDateTimes.ts index 225e46a..0d61189 100644 --- a/src/hooks/home/useDateTimes.ts +++ b/src/hooks/home/useDateTimes.ts @@ -1,8 +1,8 @@ import { useCoreQuery } from '@/hooks/customQuery'; import { getDateTimeStates } from '@/api/home/dateTimes'; -import { HomeKeys } from '@/queryKey/queryKey'; +import { homeKeys } from '@/queryKey/queryKey'; export const useDateTimeStates = () => { - return useCoreQuery(HomeKeys.dateTimes().queryKey, () => getDateTimeStates(), { staleTime: 5 * 60 * 1000, gcTime: 15 * 60 * 1000, retry: 3 }); + return useCoreQuery(homeKeys.dateTimes.queryKey, getDateTimeStates); }; diff --git a/src/hooks/home/useKeywordStates.ts b/src/hooks/home/useKeywordStates.ts index 3cc4d4d..9333783 100644 --- a/src/hooks/home/useKeywordStates.ts +++ b/src/hooks/home/useKeywordStates.ts @@ -1,11 +1,11 @@ import { useCoreQuery } from '@/hooks/customQuery'; import { getWeeklyKeywords } from '@/api/home/keyword'; -import { HomeKeys } from '@/queryKey/queryKey'; +import { homeKeys } from '@/queryKey/queryKey'; // 이번 주 인기 키워드 훅 export const useWeeklyKeywords = () => { - return useCoreQuery(HomeKeys.keywords().queryKey, getWeeklyKeywords, { + return useCoreQuery(homeKeys.keywords.queryKey, getWeeklyKeywords, { gcTime: 30 * 60 * 1000, retry: 3, }); diff --git a/src/hooks/home/useUserGrade.ts b/src/hooks/home/useUserGrade.ts index bc79b16..bc94eec 100644 --- a/src/hooks/home/useUserGrade.ts +++ b/src/hooks/home/useUserGrade.ts @@ -1,11 +1,11 @@ import { useCoreQuery } from '@/hooks/customQuery'; import { getUserGrade } from '@/api/home/level'; -import { HomeKeys } from '@/queryKey/queryKey'; +import { homeKeys } from '@/queryKey/queryKey'; // 사용자 등급 정보 훅 export const useUserGrade = () => { - return useCoreQuery(HomeKeys.getUserGrade().queryKey, () => getUserGrade(), { + return useCoreQuery(homeKeys.getUserGrade().queryKey, () => getUserGrade(), { gcTime: 15 * 60 * 1000, // 15분 retry: 3, }); diff --git a/src/hooks/home/useUserRegion.ts b/src/hooks/home/useUserRegion.ts index bcb7ac9..0f8542b 100644 --- a/src/hooks/home/useUserRegion.ts +++ b/src/hooks/home/useUserRegion.ts @@ -1,12 +1,12 @@ import { useCoreMutation, useCoreQuery } from '../customQuery'; import { getUserRegion, patchUserRegion } from '@/api/home/region'; -import { HomeKeys } from '@/queryKey/queryKey'; +import { homeKeys } from '@/queryKey/queryKey'; export function useUserRegion() { return useCoreMutation(patchUserRegion); } export function useGetUserRegion() { - return useCoreQuery(HomeKeys.userRegion().queryKey, getUserRegion); + return useCoreQuery(homeKeys.userRegion().queryKey, getUserRegion); } diff --git a/src/hooks/home/useWeather.ts b/src/hooks/home/useWeather.ts index cb9cc3c..203c4cc 100644 --- a/src/hooks/home/useWeather.ts +++ b/src/hooks/home/useWeather.ts @@ -1,18 +1,18 @@ -import { getPrecipitation, getWeeklyWeatherRecommendation } from '../../api/home/weather'; -import { useCoreQuery } from '../customQuery'; +import { useCoreQuery } from '@/hooks/customQuery'; -import { HomeKeys } from '@/queryKey/queryKey'; +import { getPrecipitation, getWeeklyWeatherRecommendation } from '@/api/home/weather'; +import { homeKeys } from '@/queryKey/queryKey'; // 주간 날씨 추천 훅 export const useWeatherForecast = ({ startDate, regionId }: { startDate: string; regionId: number }) => { - return useCoreQuery(HomeKeys.weather(startDate, regionId).queryKey, () => getWeeklyWeatherRecommendation({ startDate, regionId: regionId! }), { + return useCoreQuery(homeKeys.weather(startDate, regionId).queryKey, () => getWeeklyWeatherRecommendation({ startDate, regionId }), { staleTime: 1000 * 60 * 30, enabled: !!startDate && !!regionId, }); }; export const useRainyInfo = ({ startDate, regionId }: { startDate: string; regionId: number }) => { - return useCoreQuery(HomeKeys.rainyInfo(startDate, regionId).queryKey, () => getPrecipitation({ startDate, regionId: regionId! }), { + return useCoreQuery(homeKeys.rainyInfo(startDate, regionId).queryKey, () => getPrecipitation({ startDate, regionId }), { staleTime: 1000 * 60 * 30, enabled: !!startDate && !!regionId, }); diff --git a/src/hooks/settingAlarm/useAlarms.ts b/src/hooks/settingAlarm/useAlarms.ts index bac45af..de1096d 100644 --- a/src/hooks/settingAlarm/useAlarms.ts +++ b/src/hooks/settingAlarm/useAlarms.ts @@ -1,15 +1,13 @@ // hooks/settingAlarm/useAlarms.ts -import { useQueryClient } from '@tanstack/react-query'; - -import type { TAlarmSettings, TGetAlarmSettingsResp, TPatchAlarmSettingsResp } from '@/types/settingAlarm/alarm'; import { useCoreMutation, useCoreQuery } from '@/hooks/customQuery'; import { getAlarmSettings, patchAlarmSettings } from '@/api/settingAlarm/alarm'; +import { alarmKeys } from '@/queryKey/queryKey'; // 조회 export function useGetAlarmSettings() { - return useCoreQuery(['alarmSettings'], getAlarmSettings, { + return useCoreQuery(alarmKeys.alarmSettings().queryKey, getAlarmSettings, { select: (resp) => resp.result, refetchOnWindowFocus: false, }); @@ -17,10 +15,5 @@ export function useGetAlarmSettings() { // 업데이트 export function usePatchAlarmSettings() { - const qc = useQueryClient(); - return useCoreMutation(patchAlarmSettings, { - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['alarmSettings'] }); - }, - }); + return useCoreMutation(patchAlarmSettings); } diff --git a/src/layout/minimalLayout.tsx b/src/layout/minimalLayout.tsx new file mode 100644 index 0000000..846ca3b --- /dev/null +++ b/src/layout/minimalLayout.tsx @@ -0,0 +1,14 @@ +import { Outlet } from 'react-router-dom'; + +import Header from '@/components/layout/Header'; + +function MinimalLayout() { + return ( +
+
+ +
+ ); +} + +export default MinimalLayout; diff --git a/src/pages/TestInputPage.tsx b/src/pages/TestInputPage.tsx index a3e0a86..4fa5ea4 100644 --- a/src/pages/TestInputPage.tsx +++ b/src/pages/TestInputPage.tsx @@ -24,9 +24,7 @@ export default function TestInputPage() { placeholder="검색어 입력를 입력하세요" value={search} onChange={(e) => setSearch(e.target.value)} // 입력 상태 저장 - onSearchClick={() => { - console.log('검색 실행:', search); // 검색 아이콘 클릭 시 예시 - }} + onSearchClick={() => {}} />
@@ -38,7 +36,7 @@ export default function TestInputPage() { value={nickname} onChange={(e) => setNickname(e.target.value)} onCancel={() => setNickname('')} - onSubmit={() => console.log('닉네임 저장:', nickname)} + onSubmit={() => {}} />
diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index d7db6cd..d1efcce 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -55,7 +55,7 @@ export default function Login() { navigate('/home'); }, onError: (err) => { - console.log(err.response?.data.message); + console.error(err.response?.data.message); setError('잘못된 정보를 입력하였습니다.'); }, }, diff --git a/src/pages/auth/UserSetting.tsx b/src/pages/auth/UserSetting.tsx index 22102e9..6ebf710 100644 --- a/src/pages/auth/UserSetting.tsx +++ b/src/pages/auth/UserSetting.tsx @@ -8,7 +8,7 @@ import type { TUserSettingFormValues } from '@/types/auth/auth'; import { Gender } from '@/types/auth/auth'; import formatDateInput from '@/utils/formatDateInput'; -import formatInputNumber from '@/utils/formatPhoneNumber'; +import formatPhoneNumber from '@/utils/formatPhoneNumber'; import { userSettingSchema } from '@/utils/validation'; import { useAuth } from '@/hooks/auth/useAuth'; @@ -21,7 +21,6 @@ import Button from '../../components/common/Button'; import useAuthStore from '@/store/useAuthStore'; export default function User() { - const [error, setError] = useState(''); const [gender, setGender] = useState(Gender.MALE); const [agree1, setAgree1] = useState(false); const [agree2, setAgree2] = useState(false); @@ -40,6 +39,7 @@ export default function User() { resolver: zodResolver(userSettingSchema), defaultValues: { gender: Gender.MALE, + phoneNum: '010-', }, }); const { mutate: signupMutate, isPending } = useDefaultSignup; @@ -58,12 +58,12 @@ export default function User() { }, { onSuccess: () => { - setSocialId(-1); - navigate('/home'); + setSocialId(socialId ?? -1); + alert('성공적으로 회원가입되었습니다.'); + navigate('/'); }, onError: (err) => { - console.log(err); - setError(err.response?.data.message!); + alert(err.response?.data.message ?? '회원가입 중 문제가 발생했습니다.'); }, }, ); @@ -71,11 +71,11 @@ export default function User() { }; return ( -
+
navigate('/Join')} />
-
+
회원가입
@@ -87,7 +87,7 @@ export default function User() { children={'남자'} size="big-32" variant={`${gender == Gender.MALE ? 'mint' : 'white'}`} - className="px-[32px] !py-[16px]" + className="px-[32px] !py-[12px]" />
{ - const formatted = formatInputNumber(e.target.value); - onChange({ ...e, target: { ...e.target, value: formatted } }); + const formatted = formatPhoneNumber(e.target.value); + onChange(formatted); }} ref={ref} placeholder="전화번호 (010-xxxx-xxxx)" title="Phone Number" + error={!!errors.phoneNum?.message} + errorMessage={errors.phoneNum?.message} /> )} />
전화번호는 이메일을 잊었을 때 찾기 위한 용도입니다. 정확하게 기재해주세요. @@ -163,13 +165,12 @@ export default function User() { 이용약관 동의
-
{error}
+
+
+ navigate('/home')} type="backward" /> + {/* 제목 */} +

결제 내역 확인

+
+
+ {/* 테이블 */} +
+ + + + + + + + + + + + {pageData.length === 0 ? ( + + + + ) : ( + pageData.map((item, index) => ) + )} + +
주문번호결제일금액결제 수단결제 상태 (성공/실패 등)
+ 결제 내역이 없습니다. +
+
- {/* 제목 */} -

결제 내역 확인

+ {/* 페이지네이션*/} - {/* 테이블 */} -
- - - - - - - - - - - - {pageData.length === 0 ? ( - - - - ) : ( - pageData.map((item, index) => ) - )} - -
주문번호결제일금액결제 수단결제 상태 (성공/실패 등)
- 결제 내역이 없습니다. -
+
- - {/* 페이지네이션*/} -
); diff --git a/src/providers/deviceTokenProvider.tsx b/src/providers/deviceTokenProvider.tsx new file mode 100644 index 0000000..fd4edfc --- /dev/null +++ b/src/providers/deviceTokenProvider.tsx @@ -0,0 +1,123 @@ +// src/providers/DeviceTokenProvider.tsx +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { type QueryKey } from '@tanstack/react-query'; +import { isSupported, onMessage } from 'firebase/messaging'; + +import { useFirebase } from '@/hooks/alarm/usePostDeviceToken'; + +import { queryClient } from '@/api/queryClient'; +import { deleteFcmToken, generateToken, messaging, registerServiceWorker } from '@/firebase/firebase'; + +type TDeviceTokenContextValue = { + token: string | null; + supported: boolean | null; + permission: NotificationPermission | null; + requestAndRegister: () => Promise; + unregisterToken: () => Promise; +}; + +const DeviceTokenContext = createContext(null); + +type TProps = { + children: React.ReactNode; + refetchKeys?: QueryKey[]; + onForegroundMessage?: (payload: unknown) => void; +}; + +export function DeviceTokenProvider({ children, refetchKeys = [], onForegroundMessage }: TProps) { + const [token, setToken] = useState(null); + const [supported, setSupported] = useState(null); + const [permission, setPermission] = useState(null); + const messageUnsubRef = useRef<(() => void) | null>(null); + const initOnceRef = useRef(false); + const { postDeviceTokenMutation } = useFirebase(); + const { mutate: postDeviceToken } = postDeviceTokenMutation; + useEffect(() => { + (async () => { + const ok = await isSupported().catch(() => false); + setSupported(ok); + setPermission(typeof window !== 'undefined' && 'Notification' in window ? Notification.permission : null); + })(); + }, []); + + const wireOnMessage = useCallback(() => { + if (!messaging || messageUnsubRef.current) return; + const unsub = onMessage(messaging, (payload) => { + refetchKeys.forEach((key) => { + void queryClient.invalidateQueries({ queryKey: key }); + }); + onForegroundMessage?.(payload); + }); + messageUnsubRef.current = unsub; + }, [onForegroundMessage, queryClient, refetchKeys]); + + const requestAndRegister = useCallback(async () => { + if (supported === false) { + console.warn('FCM은 현재 브라우저에서 지원되지 않습니다.'); + return; + } + if (initOnceRef.current) return; + initOnceRef.current = true; + + try { + await registerServiceWorker(); + const newToken = await generateToken(); + setPermission(typeof window !== 'undefined' && 'Notification' in window ? Notification.permission : null); + + if (newToken) { + setToken(newToken); + postDeviceToken( + { deviceToken: newToken }, + { + onError: () => { + console.warn('FCM 토큰 등록 실패'); + initOnceRef.current = false; + }, + onSuccess: () => { + initOnceRef.current = true; + }, + }, + ); + wireOnMessage(); + } else { + console.warn('FCM 토큰 발급 실패 또는 권한 거부.'); + initOnceRef.current = false; + } + } catch (err) { + console.error('FCM 초기화 실패:', err); + initOnceRef.current = false; + } + }, [supported, wireOnMessage, postDeviceToken]); + + const unregisterToken = useCallback(async () => { + try { + await deleteFcmToken().catch(() => {}); + } finally { + setToken(null); + initOnceRef.current = false; + messageUnsubRef.current?.(); + messageUnsubRef.current = null; + for (const key of refetchKeys) void queryClient.invalidateQueries({ queryKey: key }); + } + }, [queryClient, refetchKeys]); + + useEffect(() => { + return () => { + messageUnsubRef.current?.(); + messageUnsubRef.current = null; + }; + }, []); + + const value = useMemo( + () => ({ token, supported, permission, requestAndRegister, unregisterToken }), + [token, supported, permission, requestAndRegister, unregisterToken], + ); + + return {children}; +} + +export function useDeviceTokenContext() { + const ctx = useContext(DeviceTokenContext); + if (!ctx) throw new Error('useDeviceTokenContext must be used within DeviceTokenProvider'); + return ctx; +} diff --git a/src/queryKey/queryKey.ts b/src/queryKey/queryKey.ts index 0b60752..44372b8 100644 --- a/src/queryKey/queryKey.ts +++ b/src/queryKey/queryKey.ts @@ -1,26 +1,30 @@ import { createQueryKeys } from '@lukemorales/query-key-factory'; export const regionKeys = createQueryKeys('region', { - search: (keyword: string) => [keyword], + search: (keyword: string) => ['search', keyword], }); export const alarmKeys = createQueryKeys('alarm', { - getAlarm: (size: number, cursor?: number) => [size, cursor], + getAlarm: (size: number, cursor?: number) => ['getAlarm', size, cursor], + alarmSettings: () => ['alarmSettings'], }); -export const HomeKeys = createQueryKeys('home', { - all: () => ['home'], - getUserGrade: () => ['home', 'user', 'grade'], - dateCourseSave: () => ['home', 'date-courses', 'saved-count'], - weather: (startDate, regionId) => ['home', 'weather', 'forecast', startDate, regionId], - rainyInfo: (startDate, regionId) => ['home', 'rainy', 'forecast', startDate, regionId], - keywords: () => ['home', 'keywords'], - dateTimes: () => ['home', 'dateTimes'], - monthlyPlaceStates: () => ['home', 'monthlyPlaceStates'], - userRegion: () => ['home', 'user', 'region'], +export const homeKeys = createQueryKeys('home', { + getUserGrade: () => ['user', 'grade'], + dateCourseSave: () => ['date-courses', 'saved-count'], + weather: (startDate: string, regionId: number) => ['weather', 'forecast', startDate, regionId], + rainyInfo: (startDate: string, regionId: number) => ['rainy', 'forecast', startDate, regionId], + keywords: null, + dateTimes: null, + monthlyPlaceStates: null, + userRegion: () => ['user', 'region'], }); -export const NoticeKeys = createQueryKeys('notice', { - all: () => ['notice'], - getAllNotices: (page: number, size: number, noticeCategory: 'SERVICE' | 'SYSTEM') => ['notice', page, size, noticeCategory], +export const noticeKeys = createQueryKeys('notice', { + getAllNotices: (page: number, size: number, noticeCategory: 'SERVICE' | 'SYSTEM') => [page, size, noticeCategory], +}); +export const memberKeys = createQueryKeys('member', { + memberInfo: null, + memberGrade: null, + // memberKeys안에 있는 걸 모두 초기화 하고 싶으면 alarmKeys._def로 호출하면 됩니다! }); diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index 3dd5cc1..e042aae 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -1,10 +1,10 @@ -import type { PropsWithChildren } from 'react'; -import { createBrowserRouter, Navigate } from 'react-router-dom'; +import { createBrowserRouter } from 'react-router-dom'; import ModalProvider from '@/components/common/modalProvider'; import AuthLayout from '@/layout/authLayout'; import Layout from '@/layout/layout'; +import MinimalLayout from '@/layout/minimalLayout'; import FindPw from '@/pages/auth/FindPw'; import Join from '@/pages/auth/JoinPage'; import Login from '@/pages/auth/LoginPage'; @@ -29,17 +29,6 @@ import DeleteConfirmPage from '@/pages/setting/DeleteConfirmPage'; import DeleteReasonPage from '@/pages/setting/DeleteReasonPage.tsx'; import PaymentHistory from '@/pages/setting/PaymentHistory'; -function ProtectedRoute({ children }: PropsWithChildren) { - //추후 실제 로그인 여부로 대체 필요 - const isLoggedIn = true; - - if (!isLoggedIn) { - return ; - } - - return children; -} - const router = createBrowserRouter([ { path: '/', @@ -75,10 +64,10 @@ const router = createBrowserRouter([ { path: '/', element: ( - + <> - + ), errorElement: , children: [ @@ -137,37 +126,27 @@ const router = createBrowserRouter([ ], }, { - path: '/paymentHistory', - element: ( - - - - ), - }, - { - path: '/deleteAccount', - element: ( - - - - ), - }, - { - path: '/deleteAccount/confirm', - element: ( - - - - ), - }, - { - path: '/withdraw', - element: ( - - - - ), + path: '/', + element: , errorElement: , + children: [ + { + path: 'paymentHistory', + element: , + }, + { + path: 'deleteAccount', + element: , + }, + { + path: 'deleteAccount/confirm', + element: , + }, + { + path: 'withdraw', + element: , + }, + ], }, ]); diff --git a/src/types/auth/account.ts b/src/types/auth/account.ts index 581856f..619ea88 100644 --- a/src/types/auth/account.ts +++ b/src/types/auth/account.ts @@ -1,7 +1,7 @@ import type { UseMutationResult } from '@tanstack/react-query'; import type { AxiosError } from 'axios'; -import type { TCommonResponse, TUseMutationCustomOptions } from '@/types/common/common'; +import type { TCommonResponse } from '@/types/common/common'; export type TChangePasswordPayload = { currentPassword: string; @@ -17,11 +17,11 @@ export type TChangeNicknameResponse = { }; // 비밀번호 변경 훅 타입 -export type TChangePasswordMutationOptions = TUseMutationCustomOptions; + export type TChangePasswordMutationResult = UseMutationResult; // 닉네임 변경 훅 타입 -export type TChangeNicknameMutationOptions = TUseMutationCustomOptions; + export type TChangeNicknameMutationResult = UseMutationResult; // 사용자 정보 타입 diff --git a/src/types/common/common.ts b/src/types/common/common.ts index 535d46e..0b5f44a 100644 --- a/src/types/common/common.ts +++ b/src/types/common/common.ts @@ -14,10 +14,21 @@ export type TResponseError = AxiosError<{ error: string; }>; -export type TUseMutationCustomOptions = Omit< - UseMutationOptions, - 'mutationFn' ->; +export type TOptimisticUpdate = { + key: QueryKey; + updateFn: (old: TCache | undefined, vars: TVariables) => TCache; +}; + +export type TUseMutationCustomOptions = Omit< + UseMutationOptions, + 'mutationFn' | 'onMutate' | 'onError' | 'onSuccess' +> & { + optimisticUpdate?: TOptimisticUpdate; + invalidateKeys?: QueryKey[]; + // 사용자 콜백 분리(선택): 원한다면 UseMutationOptions의 onError/onSuccess를 감싸 별도 이름으로 사용 + userOnError?: (error: TError, variables: TVariables, context: TContext | undefined) => void; + userOnSuccess?: (data: TData, variables: TVariables, context: TContext | undefined) => void; +}; export type TUseQueryCustomOptions = Omit< UseQueryOptions,