diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js index 6bdb526..d6f8184 100644 --- a/public/firebase-messaging-sw.js +++ b/public/firebase-messaging-sw.js @@ -24,8 +24,20 @@ messaging.onBackgroundMessage((payload) => { const notificationTitle = payload.notification.title; const notificationOptions = { body: payload.notification.body, - icon: "/icon.png", // 아이콘 이미지 경로 + icon: "./src/assets/icon.png", // 아이콘 이미지 경로 + data: { click_action: payload.data.click_action } // 클릭 시 이동할 URL을 data에 추가 }; self.registration.showNotification(notificationTitle, notificationOptions); }); + +// 알림 클릭 이벤트 리스너 추가 +self.addEventListener("notificationclick", (event) => { + event.notification.close(); // 알림 닫기 + + // 알림에 설정된 URL로 이동 + const targetUrl = event.notification.data.click_action || "https://splanet.co.kr/"; + event.waitUntil( + clients.openWindow(targetUrl) + ); +}); diff --git a/src/api/hooks/useFcmOffsetUpdate.ts b/src/api/hooks/useFcmOffsetUpdate.ts new file mode 100644 index 0000000..6f82b75 --- /dev/null +++ b/src/api/hooks/useFcmOffsetUpdate.ts @@ -0,0 +1,34 @@ +import { useMutation } from "@tanstack/react-query"; +import { apiClient } from "../instance"; + +interface FcmOffsetUpdateRequest { + token: string; + notificationOffset: number; +} + +type FcmOffsetUpdateResponse = string; + +const useFcmOffsetUpdate = () => { + return useMutation({ + mutationFn: async (data: FcmOffsetUpdateRequest) => { + // query parameter로 변경 + const response = await apiClient.put( + "/api/fcm/update/notification-offset", + null, // body는 null로 설정 + { + params: { + // query parameters로 전송 + token: data.token, + notificationOffset: data.notificationOffset, + }, + }, + ); + return response.data; + }, + onError: (error) => { + console.error("FCM 알림 오프셋 업데이트 실패:", error); + }, + }); +}; + +export default useFcmOffsetUpdate; diff --git a/src/api/hooks/useFcmUpdate.ts b/src/api/hooks/useFcmUpdate.ts index b4643b6..b856572 100644 --- a/src/api/hooks/useFcmUpdate.ts +++ b/src/api/hooks/useFcmUpdate.ts @@ -1,33 +1,35 @@ import { useMutation } from "@tanstack/react-query"; -import { apiClient } from "../instance"; +import { apiClient } from "@/api/instance"; -interface FcmUpdateRequest { +interface UpdateNotificationEnabledRequest { token: string; - isNotificatioinEnabled?: boolean; - notificationOffset?: number; + isNotificationEnabled: boolean; } -interface FcmUpdateResponse { - message: string; -} - -const useFcmUpdate = () => { - return useMutation({ - mutationFn: async (data: FcmUpdateRequest) => { - const response = await apiClient.put( - "/api/fcm/update", +export const useUpdateNotificationEnabled = () => { + return useMutation({ + mutationFn: async ({ + token, + isNotificationEnabled, + }: UpdateNotificationEnabledRequest) => { + const response = await apiClient.put( + `/api/fcm/update/notification-enabled?token=${token}&isNotificationEnabled=${isNotificationEnabled}`, + {}, // 빈 body { - token: data.token, - isNotificationEnabled: data.isNotificatioinEnabled ?? true, - notificationOffset: data.notificationOffset ?? 0, + withCredentials: true, }, ); + return response.data; }, - onError: (error) => { - console.error("FCM 토큰 업데이트 실패:", error); + onError: (error: any) => { + if (error.response?.status === 401) { + console.error("인증 정보가 없거나 만료되었습니다."); + } else { + console.error("알림 설정 업데이트 실패:", error); + } }, }); }; -export default useFcmUpdate; +export default useUpdateNotificationEnabled; diff --git a/src/assets/icon.png b/src/assets/icon.png new file mode 100644 index 0000000..bdc825f Binary files /dev/null and b/src/assets/icon.png differ diff --git a/src/hooks/useNotificationSetup.ts b/src/hooks/useNotificationSetup.ts new file mode 100644 index 0000000..64cb927 --- /dev/null +++ b/src/hooks/useNotificationSetup.ts @@ -0,0 +1,108 @@ +import { useEffect, useRef } from "react"; +import { requestForToken, setupOnMessageListener } from "@/api/firebaseConfig"; +import { apiClient } from "@/api/instance"; + +const FCM_TOKEN_KEY = "fcm_token"; + +// 모듈 레벨에서 전역 플래그 선언 +let isTokenRequestedGlobal = false; + +const useNotificationSetup = () => { + const tokenRequestedRef = useRef(false); + + // 브라우저별 알림 설정 안내 메시지 + const openNotificationSettings = () => { + const { userAgent } = navigator; + if (userAgent.includes("Edg")) { + alert( + "Edge 설정에서 알림을 활성화해주세요:\n설정 > 쿠키 및 사이트 권한 > 알림", + ); + } else if (userAgent.includes("Chrome")) { + alert( + "Chrome 설정에서 알림을 활성화해주세요:\n설정 > 개인정보 및 보안 > 사이트 설정 > 알림", + ); + } else if ( + userAgent.includes("Safari") && + !userAgent.includes("Chrome") && + !userAgent.includes("Edg") + ) { + alert( + "Safari 설정에서 알림을 활성화해주세요:\nmacOS에서는 Safari > 설정 > 알림\niOS에서는 설정 > Safari > 알림", + ); + } else if (userAgent.includes("Firefox")) { + alert( + "Firefox 설정에서 알림을 활성화해주세요:\n설정 페이지에서 개인정보 및 보안 > 권한 > 알림", + ); + } else { + alert("알림을 활성화하려면 브라우저 설정을 확인해주세요."); + } + }; + + useEffect(() => { + const initializeFCM = async () => { + // 알림 API 지원 여부 확인 + if (!("Notification" in window)) { + alert("현재 사용 중인 브라우저는 알림 기능을 지원하지 않습니다."); + return; + } + + // 이미 토큰 요청이 진행 중이거나 완료된 경우 중복 실행 방지 + if (isTokenRequestedGlobal || tokenRequestedRef.current) { + return; + } + + try { + isTokenRequestedGlobal = true; // 전역 플래그 설정 + tokenRequestedRef.current = true; + + const existingToken = localStorage.getItem(FCM_TOKEN_KEY); + if (!existingToken) { + if (Notification.permission === "denied") { + openNotificationSettings(); + return; + } + + const permission = await Notification.requestPermission(); + if (permission === "granted") { + const fcmToken = await requestForToken(); + if (fcmToken) { + await apiClient.post("/api/fcm/register", { token: fcmToken }); + localStorage.setItem(FCM_TOKEN_KEY, fcmToken); + console.log("New FCM token registered and saved:", fcmToken); + } + } else if (permission === "denied") { + alert( + "알림 권한이 거부되었습니다. 브라우저 설정에서 알림을 활성화해주세요.", + ); + openNotificationSettings(); + } + } + + // 메시지 리스너 설정 + setupOnMessageListener(); + } catch (error) { + console.error("Failed to initialize FCM:", error); + // 에러 발생 시 플래그 초기화 + isTokenRequestedGlobal = false; + tokenRequestedRef.current = false; + } + }; + + initializeFCM(); + + return () => { + tokenRequestedRef.current = false; + }; + }, []); + + const getFCMToken = () => { + return localStorage.getItem(FCM_TOKEN_KEY); + }; + + return { + getFCMToken, + openNotificationSettings, + }; +}; + +export default useNotificationSetup; diff --git a/src/pages/Main/MainPage.tsx b/src/pages/Main/MainPage.tsx index 33225c7..7a706cc 100644 --- a/src/pages/Main/MainPage.tsx +++ b/src/pages/Main/MainPage.tsx @@ -11,9 +11,9 @@ import useDeletePlan from "@/api/hooks/useDeletePlan"; import Button from "@/components/common/Button/Button"; import Modal from "@/components/common/Modal/Modal"; import ReactDatePicker from "@/components/features/DatePicker/DatePicker"; -import { requestForToken, setupOnMessageListener } from "@/api/firebaseConfig"; import { apiClient } from "@/api/instance"; import useUserData from "@/api/hooks/useUserData"; +import useNotificationSetup from "@/hooks/useNotificationSetup"; const PageContainer = styled.div` background-color: #ffffff; @@ -83,6 +83,8 @@ const Spinner = styled.div` `; export default function MainPage() { + useNotificationSetup(); + const location = useLocation(); const navigate = useNavigate(); const { data: fetchedPlans, isLoading, error, refetch } = useGetPlans(); @@ -99,87 +101,9 @@ export default function MainPage() { const { mutateAsync: createPlan } = useCreatePlan(); const { mutateAsync: deletePlan } = useDeletePlan(); const { userData } = useUserData(); - const isTokenRegistered = useRef(false); - const hasMounted = useRef(false); const savePlanMutation = useCreatePlan(); const isPlanSaved = useRef(false); - // FCM 토큰 등록 함수 - const registerFcmToken = async () => { - // 이미 토큰이 등록되어 있다면 종료 - if (isTokenRegistered.current) { - console.log("이미 FCM 토큰이 등록되어 있습니다."); - return; - } - - // localStorage에서 토큰 확인 - const storedToken = localStorage.getItem("fcmToken"); - if (storedToken) { - console.log( - "저장된 FCM 토큰을 사용합니다:", - `${storedToken.slice(0, 10)}...`, - ); - isTokenRegistered.current = true; - return; - } - - try { - console.log("FCM 토큰 등록 시작..."); - const permission = await Notification.requestPermission(); - console.log("알림 권한 상태:", permission); - - if (permission === "granted") { - const fcmToken = await requestForToken(); - if (fcmToken) { - console.log("새로운 FCM 토큰 발급됨:", `${fcmToken.slice(0, 10)}...`); - await apiClient.post("/api/fcm/register", { token: fcmToken }); - localStorage.setItem("fcmToken", fcmToken); - isTokenRegistered.current = true; - console.log("FCM 토큰 등록 완료"); - } else { - console.warn("FCM 토큰이 null입니다."); - } - } else { - console.warn("알림 권한이 거부되었습니다."); - } - } catch (err) { - console.error("FCM 토큰 등록 중 오류 발생:", err); - } - }; - // Notification functionality (기존 코드 유지) - useEffect(() => { - const registerFcmToken = async () => { - const permission = await Notification.requestPermission(); - if (permission === "granted") { - try { - const fcmToken = await requestForToken(); - if (fcmToken) { - await apiClient.post("/api/fcm/register", { token: fcmToken }); - console.log("FCM 토큰이 성공적으로 등록되었습니다."); - } - } catch (err) { - console.error("FCM 토큰 등록 중 오류 발생:", err); - } - } else { - console.log("알림 권한이 거부되었습니다."); - } - }; - - registerFcmToken(); - setupOnMessageListener(); // Set up the listener for foreground messages - }, []); - // 앱 초기 마운트시에만 FCM 토큰 등록 및 리스너 설정 - useEffect(() => { - if (!hasMounted.current) { - console.log("FCM 초기화 시작..."); - registerFcmToken().then(() => { - console.log("FCM 초기화 완료"); - setupOnMessageListener(); - }); - hasMounted.current = true; - } - }, []); - // 플랜 데이터 초기화 useEffect(() => { if (fetchedPlans) { @@ -282,8 +206,8 @@ export default function MainPage() { accessibility: true, isCompleted: false, }); - } catch (error) { - alert(`추가 중 오류 발생: ${error}`); + } catch (err) { + alert(`추가 중 오류 발생: ${err}`); } }; @@ -296,8 +220,8 @@ export default function MainPage() { setModifiedPlans((prevPlans) => prevPlans.filter((plan) => plan.id !== planId), ); - } catch (error) { - alert(`삭제 중 오류 발생: ${error}`); + } catch (err) { + alert(`삭제 중 오류 발생: ${err}`); } } }; @@ -324,8 +248,8 @@ export default function MainPage() { // 상태 업데이트 setModifiedPlans(updatedPlans); - // 변경된 플랜들에 대해 서버에 업데이트 - for (const plan of changedPlans) { + // Promise.all을 사용하여 모든 플랜을 병렬로 업데이트 + const updatePlans = changedPlans.map(async (plan) => { try { await apiClient.put(`/api/plans/${plan.id}`, { title: plan.title, @@ -336,13 +260,23 @@ export default function MainPage() { isCompleted: plan.isCompleted ?? false, }); console.log(`플랜 ID ${plan.id}이 성공적으로 업데이트되었습니다.`); - } catch (error: unknown) { - if (error instanceof Error) { - alert(`플랜 업데이트 중 오류 발생: ${error.message}`); + } catch (err) { + if (err instanceof Error) { + alert(`플랜 업데이트 중 오류 발생: ${err.message}`); } else { alert("플랜 업데이트 중 알 수 없는 오류가 발생했습니다."); } + // 필요시 개별 에러를 처리하거나 다시 던질 수 있습니다. + throw err; } + }); + + try { + await Promise.all(updatePlans); + console.log("모든 플랜이 성공적으로 업데이트되었습니다."); + } catch (err) { + console.error("일부 플랜 업데이트에 실패했습니다.", error); + // 추가적인 에러 핸들링 로직을 여기에 작성할 수 있습니다. } }, [modifiedPlans], diff --git a/src/pages/Mypage/Mypage.tsx b/src/pages/Mypage/Mypage.tsx index f18df49..53021cc 100644 --- a/src/pages/Mypage/Mypage.tsx +++ b/src/pages/Mypage/Mypage.tsx @@ -1,10 +1,12 @@ -// src/pages/MyPage.tsx import { useState, useEffect } from "react"; import styled from "@emotion/styled"; import { motion } from "framer-motion"; import CreditCardIcon from "@mui/icons-material/CreditCard"; import NotificationsIcon from "@mui/icons-material/Notifications"; import Switch from "@mui/material/Switch"; +import Select, { SelectChangeEvent, SelectProps } from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import FormControl from "@mui/material/FormControl"; import { useNavigate } from "react-router-dom"; import List from "@/components/common/List/List"; import Button from "@/components/common/Button/Button"; @@ -12,8 +14,9 @@ import useUserData from "@/api/hooks/useUserData"; import useAuth from "@/hooks/useAuth"; import RouterPath from "@/router/RouterPath"; import breakpoints from "@/variants/breakpoints"; -import useFcmUpdate from "@/api/hooks/useFcmUpdate"; +import useNotificationSetup from "@/api/hooks/useFcmUpdate"; import { requestForToken } from "@/api/firebaseConfig"; +import useFcmOffsetUpdate from "@/api/hooks/useFcmOffsetUpdate"; const PageWrapper = styled.div` display: flex; @@ -28,7 +31,7 @@ const PageWrapper = styled.div` const ContentWrapper = styled.main` flex-grow: 1; - padding: 32px; /* p-8 */ + padding: 32px; overflow: auto; ${breakpoints.mobile} { @@ -37,10 +40,10 @@ const ContentWrapper = styled.main` `; const Heading = styled.h1` - font-size: 24px; /* text-3xl */ - font-weight: 600; /* font-semibold */ - margin-bottom: 24px; /* mb-6 */ - color: #2d3748; /* text-gray-800 */ + font-size: 24px; + font-weight: 600; + margin-bottom: 24px; + color: #2d3748; ${breakpoints.mobile} { font-size: 20px; @@ -51,7 +54,7 @@ const Heading = styled.h1` const GridLayout = styled.div` display: grid; grid-template-columns: 1fr; - gap: 24px; /* gap-6 */ + gap: 24px; @media (min-width: 768px) { grid-template-columns: 1fr 1fr; @@ -59,14 +62,15 @@ const GridLayout = styled.div` `; const Card = styled(motion.div)` - background-color: #ffffff; /* bg-white */ - border-radius: 8px; /* rounded-lg */ - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* shadow-md */ - padding: 24px; /* p-6 */ - transition: box-shadow 0.2s; /* transition-shadow duration-200 */ + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + padding: 24px; + transition: box-shadow 0.2s; margin-bottom: 18px; + &:hover { - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15); /* hover:shadow-lg */ + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15); } border: 1px solid #e5e7eb; ${breakpoints.mobile} { @@ -80,14 +84,15 @@ const ProfileCard = styled(motion.div)` display: flex; justify-content: space-between; align-items: center; - background-color: #ffffff; /* bg-white */ - border-radius: 8px; /* rounded-lg */ - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* shadow-md */ - padding: 24px; /* p-6 */ - transition: box-shadow 0.2s; /* transition-shadow duration-200 */ + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + padding: 24px; + transition: box-shadow 0.2s; margin-bottom: 18px; + &:hover { - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15); /* hover:shadow-lg */ + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15); } ${breakpoints.mobile} { @@ -101,7 +106,7 @@ const ProfileCard = styled(motion.div)` const CardHeader = styled.div` display: flex; align-items: center; - margin-bottom: 16px; /* mb-4 */ + margin-bottom: 16px; ${breakpoints.mobile} { margin-bottom: 8px; @@ -110,10 +115,10 @@ const CardHeader = styled.div` const CardTitle = styled.h3` margin: 0; - margin-left: 8px; /* ml-2 */ - font-size: 18px; /* text-xl */ - font-weight: 600; /* font-semibold */ - color: #4a5568; /* text-gray-700 */ + margin-left: 8px; + font-size: 18px; + font-weight: 600; + color: #4a5568; ${breakpoints.mobile} { font-size: 16px; @@ -122,7 +127,7 @@ const CardTitle = styled.h3` const CardContent = styled.div` font-size: 14px; - color: #4a5568; /* text-gray-700 */ + color: #4a5568; ${breakpoints.mobile} { font-size: 13px; @@ -132,7 +137,7 @@ const CardContent = styled.div` const DeleteButtonWrapper = styled.div` display: flex; justify-content: flex-end; - margin-top: 24px; /* mt-6 */ + margin-top: 24px; gap: 10px; ${breakpoints.mobile} { @@ -141,60 +146,168 @@ const DeleteButtonWrapper = styled.div` } `; +// StyledSelect를 래퍼 함수로 정의하여 제네릭 타입을 명시적으로 설정 +const StyledSelect = styled((props: SelectProps) => ( +