서비스 이용에 불편을 드려 죄송합니다
-
+
요청하신 페이지를 찾을 수 없습니다
경로가 잘못되었거나, 인터넷 연결이 불안정할 수 있습니다
diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx
index e442672..d9f5167 100644
--- a/src/pages/home/HomePage.tsx
+++ b/src/pages/home/HomePage.tsx
@@ -1,3 +1,4 @@
+import { useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import ClipLoader from 'react-spinners/ClipLoader';
@@ -13,8 +14,23 @@ import MainInfo from '@/components/home/info';
import Level from '@/components/home/level';
import WordCloudCard from '@/components/home/wordCloud';
+import { useDeviceTokenContext } from '@/providers/deviceTokenProvider';
+
function Home() {
- // useDeviceToken();
+ const { requestAndRegister } = useDeviceTokenContext();
+
+ useEffect(() => {
+ const fire = () => {
+ requestAndRegister().catch((err) => {
+ console.error('Device token 등록 실패:', err);
+ });
+ };
+
+ window.addEventListener('pointerdown', fire, { once: true });
+ return () => {
+ window.removeEventListener('pointerdown', fire);
+ };
+ }, [requestAndRegister]);
const { data: gradeData, isLoading, error } = useUserGrade();
const { useGetMemberInfo } = useAccount();
diff --git a/src/pages/setting/DeleteConfirmPage.tsx b/src/pages/setting/DeleteConfirmPage.tsx
index 4e69f46..0297afa 100644
--- a/src/pages/setting/DeleteConfirmPage.tsx
+++ b/src/pages/setting/DeleteConfirmPage.tsx
@@ -6,7 +6,9 @@ import { useAccount } from '@/hooks/auth/useAccount';
import CommonAuthInput from '@/components/auth/commonAuthInput';
import Header from '@/components/layout/Header';
+import { queryClient } from '@/api/queryClient';
import ArrowLeftCircle from '@/assets/icons/Arrow_left_circle.svg?react';
+import useAuthStore from '@/store/useAuthStore';
// 탈퇴 안내 배열
const withdrawNotices = [
@@ -32,26 +34,33 @@ export default function DeleteConfirmPage() {
// 사용자 정보 가져오기
const { data: memberData, isLoading: infoLoading, isError: infoError } = useGetMemberInfo();
+ const { setEmail, setPassword, setSocialId } = useAuthStore();
const userEmail = memberData?.result?.email ?? ''; // ← 여기서 이메일 사용
- const { mutate: deleteAccount, isPending } = useDeleteMember({
- onSuccess: () => {
- alert('회원 탈퇴가 완료되었습니다.');
- localStorage.removeItem('accessToken');
- navigate('/', { replace: true });
- },
- onError: (error) => {
- const msg = error?.response?.data?.message || '회원 탈퇴에 실패했습니다.';
- alert(msg);
- },
- });
+ const { mutate: deleteAccount, isPending } = useDeleteMember();
const allAgreed = checked.every(Boolean);
const handleDelete = () => {
if (!allAgreed) return alert('유의사항에 모두 동의해 주세요.');
if (!confirm('정말 탈퇴하시겠습니까?')) return;
- deleteAccount();
+ deleteAccount(
+ {},
+ {
+ onSuccess: () => {
+ alert('회원 탈퇴가 완료되었습니다.');
+ setEmail('');
+ setPassword('');
+ setSocialId(-1);
+ queryClient.clear();
+ navigate('/', { replace: true });
+ },
+ onError: (error) => {
+ const msg = error?.response?.data?.message || '회원 탈퇴에 실패했습니다.';
+ alert(msg);
+ },
+ },
+ );
};
const toggleCheckbox = (index: number) => {
diff --git a/src/pages/setting/PaymentHistory.tsx b/src/pages/setting/PaymentHistory.tsx
index c81dba2..7d0b323 100644
--- a/src/pages/setting/PaymentHistory.tsx
+++ b/src/pages/setting/PaymentHistory.tsx
@@ -1,12 +1,10 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
+import GraySvgButton from '@/components/common/graySvgButton';
import Navigator from '@/components/common/navigator';
-import Header from '@/components/layout/Header';
import PaymentRow from '@/components/payment/PaymentRow';
-
-import ArrowLeftCircle from '@/assets/icons/Arrow_left_circle.svg?react';
-
+// 현재 API 개발 범위에 포함되어있지 않아 dummyData로 처리하였습니다
const dummyData = [
{ orderNumber: '202219486', date: '2024.01.15', amount: '₩2,900', method: '카카오페이', status: '환불처리' },
{ orderNumber: '202219487', date: '2023.05.15', amount: '₩2,900', method: '신한카드', status: '결제 완료' },
@@ -31,47 +29,45 @@ export default function PaymentHistory() {
}, [currentPage, itemsPerPage]);
return (
-
+
{/* 헤더 */}
-
-
-
- {/* 뒤로가기*/}
-
+
+
+ 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,