Skip to content

Commit 4a2feb1

Browse files
committed
feat: 2차 피드백 반영
1 parent 6fdc4c2 commit 4a2feb1

File tree

12 files changed

+273
-66
lines changed

12 files changed

+273
-66
lines changed

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export default [
3434
{
3535
rules: {
3636
'prettier/prettier': 'warn',
37+
'no-console': ['error', { allow: ['warn', 'error'] }],
3738
},
3839
},
3940
tsConfig,

src/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import './App.css';
22

33
import { RouterProvider } from 'react-router-dom';
44

5+
import { DeviceTokenProvider } from './providers/deviceTokenProvider';
6+
import { alarmKeys } from './queryKey/queryKey';
7+
58
import router from '@/routes/routes';
69

710
function App() {
8-
return <RouterProvider router={router} />;
11+
return (
12+
<DeviceTokenProvider refetchKeys={[alarmKeys.all().queryKey]}>
13+
<RouterProvider router={router} />
14+
</DeviceTokenProvider>
15+
);
916
}
1017

1118
export default App;

src/components/dateCourse/dateCourse.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ function DateCourse({ defaultOpen = false }: { defaultOpen?: boolean }) {
2020
} else {
2121
setIsBookmarked(!isBookmarked);
2222
}
23-
// console.log('북마크 해제');
2423
};
2524

2625
useEffect(() => {

src/components/dateCourse/dateCourseSearchFilterOption.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export default function DateCourseSearchFilterOption({ options, type, value, onC
8888
mode="search"
8989
onSearchClick={handleSearch}
9090
placeholder="ex: 서울시 강남구"
91-
className="w-full"
91+
className="!w-full min-w-full"
9292
value={inputValue}
9393
onChange={handleInputChange}
9494
/>

src/firebase/firebase.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// src/firebase/firebase.ts
22
import { initializeApp } from 'firebase/app';
3-
import { getMessaging, getToken } from 'firebase/messaging';
3+
import type { Messaging } from 'firebase/messaging';
4+
import { deleteToken, getMessaging, getToken, isSupported } from 'firebase/messaging';
45

56
const firebaseConfig = {
67
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
@@ -13,22 +14,34 @@ const firebaseConfig = {
1314
};
1415

1516
const app = initializeApp(firebaseConfig);
16-
const messaging = getMessaging(app);
17+
export let messaging: Messaging | null = null;
18+
(async () => {
19+
if (await isSupported()) {
20+
messaging = getMessaging(app);
21+
}
22+
})();
23+
24+
export async function generateToken(): Promise<string | null> {
25+
if (!(await isSupported())) return null;
26+
if (!messaging) messaging = getMessaging(app);
27+
28+
// 권한 요청 (이미 허용/거부된 상태면 브라우저가 적절히 동작)
29+
if ('Notification' in window && Notification.permission !== 'granted') {
30+
const perm = await Notification.requestPermission();
31+
if (perm !== 'granted') return null;
32+
}
1733

18-
export const generateToken = async () => {
1934
try {
2035
const token = await getToken(messaging, {
2136
vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
37+
serviceWorkerRegistration: await navigator.serviceWorker.getRegistration(),
2238
});
23-
if (!token) {
24-
console.warn('FCM 토큰 생성에 실패했습니다. 알림 권한을 확인해주세요.');
25-
}
26-
return token;
27-
} catch (error) {
28-
console.error('FCM 토큰 생성 중 오류 발생:', error);
39+
return token ?? null;
40+
} catch (e) {
41+
console.error('FCM getToken 실패:', e);
2942
return null;
3043
}
31-
};
44+
}
3245

3346
export const registerServiceWorker = async () => {
3447
try {
@@ -39,3 +52,14 @@ export const registerServiceWorker = async () => {
3952
console.error('Service Worker registration failed:', err);
4053
}
4154
};
55+
56+
export async function deleteFcmToken(): Promise<boolean> {
57+
if (!(await isSupported())) return false;
58+
if (!messaging) messaging = getMessaging(app);
59+
try {
60+
return await deleteToken(messaging);
61+
} catch (e) {
62+
console.error('FCM deleteToken 실패:', e);
63+
return false;
64+
}
65+
}

src/hooks/alarm/useDeviceToken.ts

Lines changed: 0 additions & 39 deletions
This file was deleted.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { useCoreMutation } from '../customQuery';
2+
3+
import { postDeviceToken } from '@/api/alarm/alarm';
4+
5+
export function useFirebase() {
6+
const usePostDeviceToken = useCoreMutation(postDeviceToken);
7+
return { usePostDeviceToken };
8+
}

src/hooks/customQuery.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { type MutationFunction, type QueryFunction, type QueryKey, useMutation, useQuery, type UseQueryResult } from '@tanstack/react-query';
22
import type { AxiosError } from 'axios';
3-
import { toast } from 'sonner';
43

54
import type { TUseMutationCustomOptions, TUseQueryCustomOptions } from '@/types/common/common';
65

6+
import { queryClient } from '@/api/queryClient';
7+
78
export function useCoreQuery<TQueryFnData, TData = TQueryFnData>(
89
keyName: QueryKey,
910
query: QueryFunction<TQueryFnData, QueryKey>,
@@ -17,12 +18,53 @@ export function useCoreQuery<TQueryFnData, TData = TQueryFnData>(
1718
});
1819
}
1920

20-
export function useCoreMutation<T, U>(mutation: MutationFunction<T, U>, options?: TUseMutationCustomOptions) {
21-
return useMutation({
21+
export function useCoreMutation<TData, TVariables>(
22+
mutation: MutationFunction<TData, TVariables>,
23+
options?: TUseMutationCustomOptions<TData, TVariables, AxiosError<{ message?: string }>, { prevData?: unknown }>,
24+
) {
25+
const qc = queryClient;
26+
27+
const {
28+
optimisticUpdate,
29+
invalidateKeys,
30+
userOnError,
31+
userOnSuccess,
32+
...rest // retry, gcTime 등 표준 옵션
33+
} = options ?? {};
34+
35+
return useMutation<TData, AxiosError<{ message?: string }>, TVariables, { prevData?: unknown }>({
2236
mutationFn: mutation,
23-
onError: (error) => {
24-
toast.error(error.response?.data.message || 'An error occurred.');
37+
38+
onMutate: async (vars) => {
39+
if (!optimisticUpdate) return {};
40+
await qc.cancelQueries({ queryKey: optimisticUpdate.key });
41+
const prevData = qc.getQueryData(optimisticUpdate.key);
42+
qc.setQueryData(optimisticUpdate.key, (old: any) => optimisticUpdate.updateFn(old, vars));
43+
return { prevData };
2544
},
26-
...options,
45+
46+
onError: (error, vars, ctx) => {
47+
// 롤백
48+
if (optimisticUpdate && ctx?.prevData !== undefined) {
49+
qc.setQueryData(optimisticUpdate.key, ctx.prevData);
50+
}
51+
52+
// 사용자 콜백 위임
53+
userOnError?.(error, vars, ctx);
54+
},
55+
56+
onSuccess: async (data, vars, ctx) => {
57+
// invalidate
58+
if (invalidateKeys?.length) {
59+
for (const key of invalidateKeys) {
60+
await qc.invalidateQueries({ queryKey: key });
61+
}
62+
}
63+
// 사용자 콜백 위임
64+
userOnSuccess?.(data, vars, ctx);
65+
},
66+
67+
// 나머지 표준 옵션 주입
68+
...rest,
2769
});
2870
}

src/pages/home/HomePage.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useDeviceToken } from '@/hooks/alarm/useDeviceToken';
1+
import { useEffect } from 'react';
22

33
import Banner from '@/components/home/banner';
44
import DateCourseStore from '@/components/home/dateCourseStore';
@@ -9,8 +9,23 @@ import MainInfo from '@/components/home/info';
99
import Level from '@/components/home/level';
1010
import WordCloudCard from '@/components/home/wordCloud';
1111

12+
import { useDeviceTokenContext } from '@/providers/deviceTokenProvider';
13+
1214
function Home() {
13-
useDeviceToken();
15+
const { requestAndRegister } = useDeviceTokenContext();
16+
17+
useEffect(() => {
18+
const fire = () => {
19+
requestAndRegister().catch((err) => {
20+
console.error('Device token 등록 실패:', err);
21+
});
22+
};
23+
24+
window.addEventListener('pointerdown', fire, { once: true });
25+
return () => {
26+
window.removeEventListener('pointerdown', fire);
27+
};
28+
}, [requestAndRegister]);
1429

1530
return (
1631
<div className="bg-default-gray-100 min-h-screen mb-[40px]">
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// src/providers/DeviceTokenProvider.tsx
2+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
3+
import { type QueryKey } from '@tanstack/react-query';
4+
import { isSupported, onMessage } from 'firebase/messaging';
5+
6+
import { useFirebase } from '@/hooks/alarm/usePostDeviceToken';
7+
8+
import { queryClient } from '@/api/queryClient';
9+
import { deleteFcmToken, generateToken, messaging, registerServiceWorker } from '@/firebase/firebase';
10+
11+
type TDeviceTokenContextValue = {
12+
token: string | null;
13+
supported: boolean | null;
14+
permission: NotificationPermission | null;
15+
requestAndRegister: () => Promise<void>;
16+
unregisterToken: () => Promise<void>;
17+
};
18+
19+
const DeviceTokenContext = createContext<TDeviceTokenContextValue | null>(null);
20+
21+
type TProps = {
22+
children: React.ReactNode;
23+
refetchKeys?: QueryKey[];
24+
onForegroundMessage?: (payload: unknown) => void;
25+
};
26+
27+
export function DeviceTokenProvider({ children, refetchKeys = [], onForegroundMessage }: TProps) {
28+
const qc = queryClient;
29+
const [token, setToken] = useState<string | null>(null);
30+
const [supported, setSupported] = useState<boolean | null>(null);
31+
const [permission, setPermission] = useState<NotificationPermission | null>(null);
32+
const messageUnsubRef = useRef<(() => void) | null>(null);
33+
const initOnceRef = useRef(false);
34+
const { usePostDeviceToken } = useFirebase();
35+
const { mutate: usePostDeviceTokenMutate } = usePostDeviceToken;
36+
useEffect(() => {
37+
(async () => {
38+
const ok = await isSupported().catch(() => false);
39+
setSupported(ok);
40+
setPermission(typeof window !== 'undefined' && 'Notification' in window ? Notification.permission : null);
41+
})();
42+
}, []);
43+
44+
const wireOnMessage = useCallback(() => {
45+
if (!messaging || messageUnsubRef.current) return;
46+
const unsub = onMessage(messaging, (payload) => {
47+
refetchKeys.forEach((key) => {
48+
qc.invalidateQueries({ queryKey: key });
49+
});
50+
onForegroundMessage?.(payload);
51+
});
52+
messageUnsubRef.current = unsub;
53+
}, [onForegroundMessage, qc, refetchKeys]);
54+
55+
const requestAndRegister = useCallback(async () => {
56+
if (supported === false) {
57+
console.warn('FCM은 현재 브라우저에서 지원되지 않습니다.');
58+
return;
59+
}
60+
if (initOnceRef.current) return;
61+
initOnceRef.current = true;
62+
63+
try {
64+
await registerServiceWorker();
65+
const newToken = await generateToken();
66+
setPermission(typeof window !== 'undefined' ? Notification.permission : null);
67+
68+
if (newToken) {
69+
setToken(newToken);
70+
usePostDeviceTokenMutate(
71+
{ deviceToken: newToken },
72+
{
73+
onError: () => {
74+
console.warn('FCM 토큰 등록 실패');
75+
initOnceRef.current = false;
76+
},
77+
onSuccess: () => {
78+
initOnceRef.current = true;
79+
},
80+
},
81+
);
82+
wireOnMessage();
83+
} else {
84+
console.warn('FCM 토큰 발급 실패 또는 권한 거부.');
85+
initOnceRef.current = false;
86+
}
87+
} catch (err) {
88+
console.error('FCM 초기화 실패:', err);
89+
initOnceRef.current = false;
90+
}
91+
}, [supported, wireOnMessage]);
92+
93+
const unregisterToken = useCallback(async () => {
94+
try {
95+
await deleteFcmToken().catch(() => {});
96+
} finally {
97+
setToken(null);
98+
initOnceRef.current = false;
99+
for (const key of refetchKeys) qc.invalidateQueries({ queryKey: key });
100+
}
101+
}, [qc, refetchKeys]);
102+
103+
useEffect(() => {
104+
return () => {
105+
messageUnsubRef.current?.();
106+
messageUnsubRef.current = null;
107+
};
108+
}, []);
109+
110+
const value = useMemo(
111+
() => ({ token, supported, permission, requestAndRegister, unregisterToken }),
112+
[token, supported, permission, requestAndRegister, unregisterToken],
113+
);
114+
115+
return <DeviceTokenContext.Provider value={value}>{children}</DeviceTokenContext.Provider>;
116+
}
117+
118+
export function useDeviceTokenContext() {
119+
const ctx = useContext(DeviceTokenContext);
120+
if (!ctx) throw new Error('useDeviceTokenContext must be used within DeviceTokenProvider');
121+
return ctx;
122+
}

0 commit comments

Comments
 (0)