From a97775b5c96bb9d3f19e55c2cf0ccb7900fbe200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=97=B0=EC=A7=84?= Date: Tue, 5 Aug 2025 17:23:53 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20FCM=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=AA=A8=EB=8B=AC=20=EC=83=9D=EC=84=B1,=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.mjs | 2 +- package.json | 3 + public/firebase-messaging-sw.js | 42 ++ src/api/alarm/alarm.ts | 18 + src/components/alarmModal/alarm.tsx | 17 + src/components/common/graySvgButton.tsx | 4 +- src/components/common/modalProvider.tsx | 9 +- src/components/layout/Header.tsx | 16 +- src/components/layout/MobileMenu.tsx | 13 +- src/components/modal/alarmModal.tsx | 39 ++ src/firebase/firebase.ts | 33 ++ src/hooks/alarm/useDeviceToken.ts | 39 ++ src/hooks/alarm/useGetAlarm.ts | 15 + src/pages/home/HomePage.tsx | 4 + src/queryKey/queryKey.ts | 4 + src/types/alarm/alarm.ts | 22 + tsconfig.app.json | 2 +- yarn.lock | 586 +++++++++++++++++++++++- 18 files changed, 848 insertions(+), 20 deletions(-) create mode 100644 public/firebase-messaging-sw.js create mode 100644 src/api/alarm/alarm.ts create mode 100644 src/components/alarmModal/alarm.tsx create mode 100644 src/components/modal/alarmModal.tsx create mode 100644 src/firebase/firebase.ts create mode 100644 src/hooks/alarm/useDeviceToken.ts create mode 100644 src/hooks/alarm/useGetAlarm.ts create mode 100644 src/types/alarm/alarm.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 373d4d4..3787778 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,7 +10,7 @@ import tsConfig from './eslint/typescript.mjs'; export default [ { - ignores: ['src/vite-env.d.ts'], + ignores: ['src/vite-env.d.ts', 'public/firebase-messaging-sw.js'], }, { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], diff --git a/package.json b/package.json index 1a84e71..2c046f5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "chart.js": "^4.5.0", "chartjs-plugin-datalabels": "^2.2.0", "clsx": "^2.1.1", + "firebase": "^12.0.0", "lodash": "^4.17.21", "lodash.throttle": "^4.1.1", "path": "^0.12.7", @@ -30,7 +31,9 @@ "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", + "react-intersection-observer": "^9.16.0", "react-router-dom": "^7.6.0", + "react-spinners": "^0.17.0", "sonner": "^2.0.3", "tailwindcss": "^4.1.7", "wordcloud": "^1.2.3", diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js new file mode 100644 index 0000000..9afe48d --- /dev/null +++ b/public/firebase-messaging-sw.js @@ -0,0 +1,42 @@ +/// +/* eslint-env serviceworker */ +/* global firebase importScripts clients */ +importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js'); + +firebase.initializeApp({ + apiKey: 'AIzaSyAjZqK2lhCOeX_P2Sf-_2IGEFlORchcO5w', + authDomain: 'withtime-ff471.firebaseapp.com', + projectId: 'withtime-ff471', + storageBucket: 'withtime-ff471.firebasestorage.app', + messagingSenderId: '47995224236', + appId: '1:47995224236:web:85371605ce4a6659529f09', + measurementId: 'G-5E8Q23LL4H', +}); + +const messaging = firebase.messaging(); + +self.addEventListener('push', function (event) { + try { + const payload = event.data.json(); + const title = payload.notification.title; + + const options = { + body: payload.notification.body, + icon: payload.notification.icon, + data: payload.notification.click_action, + }; + + event.waitUntil(self.registration.showNotification(title, options)); + } catch (error) { + console.error('Push event error:', error); + } +}); + +self.addEventListener('notificationclick', function (event) { + console.log(event.notification); + + event.notification.close(); + + event.waitUntil(clients.openWindow(event.notification.data)); +}); diff --git a/src/api/alarm/alarm.ts b/src/api/alarm/alarm.ts new file mode 100644 index 0000000..499a1fc --- /dev/null +++ b/src/api/alarm/alarm.ts @@ -0,0 +1,18 @@ +import type { TRequestGetAlarm, TRequestPostDeviceToken, TResponseGetAlarm, TResponsePOstDeviceToken } from '@/types/alarm/alarm'; + +import { axiosInstance } from '../axiosInstance'; + +export const getAlarm = async ({ size = 5, cursor }: TRequestGetAlarm): Promise => { + const { data } = await axiosInstance.get('/api/v1/alarms', { + params: { + size: size, + cursor, + }, + }); + return data; +}; + +export const postDeviceToken = async ({ deviceToken }: TRequestPostDeviceToken): Promise => { + const { data } = await axiosInstance.post('/api/v1/alarms/device-tokens', { deviceToken }); + return data; +}; diff --git a/src/components/alarmModal/alarm.tsx b/src/components/alarmModal/alarm.tsx new file mode 100644 index 0000000..cbf2d5b --- /dev/null +++ b/src/components/alarmModal/alarm.tsx @@ -0,0 +1,17 @@ +import { useNavigate } from 'react-router-dom'; + +import type { TAlarm } from '@/types/alarm/alarm'; + +import ChevronForward from '@/assets/icons/default_arrows/chevron_forward.svg?react'; + +function Alarm({ title }: TAlarm) { + const navigate = useNavigate(); + return ( +
+
{title}
+ navigate('')} /> +
+ ); +} + +export default Alarm; diff --git a/src/components/common/graySvgButton.tsx b/src/components/common/graySvgButton.tsx index 9c9d2d7..d074938 100644 --- a/src/components/common/graySvgButton.tsx +++ b/src/components/common/graySvgButton.tsx @@ -1,10 +1,10 @@ -import type { ReactElement } from 'react'; +import React from 'react'; import ArrowLeftCircle from '@/assets/icons/Arrow_left_circle.svg?react'; import ErrorCircle from '@/assets/icons/Error-circle_Fill.svg?react'; type TGraySVGButton = { - child?: ReactElement; + child?: React.ReactNode; type?: 'cancle' | 'backward'; onClick: () => void; size?: 'big' | 'default'; diff --git a/src/components/common/modalProvider.tsx b/src/components/common/modalProvider.tsx index 579d80a..8b626a8 100644 --- a/src/components/common/modalProvider.tsx +++ b/src/components/common/modalProvider.tsx @@ -1,9 +1,10 @@ import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; -import DateCourseSearchFilterModal from '../modal/dateCourseSearchFilterModal'; -import ErrorModal from '../modal/errorModal'; -import SettingsModal from '../modal/SettingModal'; +import AlarmModal from '@/components/modal/alarmModal'; +import DateCourseSearchFilterModal from '@/components/modal/dateCourseSearchFilterModal'; +import ErrorModal from '@/components/modal/errorModal'; +import SettingsModal from '@/components/modal/SettingModal'; import useModalStore from '@/store/useModalStore'; @@ -13,12 +14,14 @@ export const MODAL_TYPES = { ErrorModal: 'ErrorModal', DateCourseSearchFilterModal: 'DateCourseSearchFilterModal', SettingsModal: 'SettingsModal', //설정 모달 추가 + AlarmModal: 'AlarmModal', }; export const MODAL_COMPONENTS = { [MODAL_TYPES.ErrorModal]: ErrorModal, [MODAL_TYPES.DateCourseSearchFilterModal]: DateCourseSearchFilterModal, [MODAL_TYPES.SettingsModal]: SettingsModal, + [MODAL_TYPES.AlarmModal]: AlarmModal, }; export default function ModalProvider() { diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index ccb6d17..d9ef7e5 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -2,21 +2,22 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import MobileMenu from './MobileMenu'; -import SettingsModal from '../modal/SettingModal'; +import { MODAL_TYPES } from '../common/modalProvider'; import BurgerIcon from '@/assets/icons/Burger_fill.svg?react'; import ClearIcon from '@/assets/icons/Clear.svg?react'; import NotificationsIcon from '@/assets/icons/notifications_Blank.svg?react'; import SettingsIcon from '@/assets/icons/settings_Blank.svg?react'; import NavbarLogo from '@/assets/withTimeLogo/navbarLogo.svg?react'; +import useModalStore from '@/store/useModalStore'; interface IHeaderProps { mode?: 'full' | 'minimal'; // full: nav + border | minimal: 로고만 } export default function Header({ mode = 'full' }: IHeaderProps) { - const [isSettingsOpen, setIsSettingsOpen] = useState(false); //설정 모달 const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); // 모바일 메뉴 + const { openModal } = useModalStore(); const showNav = mode === 'full'; const showBorder = mode === 'full'; @@ -56,10 +57,10 @@ export default function Header({ mode = 'full' }: IHeaderProps) { {/* 아이콘 버튼 */}
- + +
@@ -83,10 +84,7 @@ export default function Header({ mode = 'full' }: IHeaderProps) { {/* 모바일 메뉴 */} - {isMobileMenuOpen && setIsMobileMenuOpen(false)} onOpenSettings={() => setIsSettingsOpen(true)} />} - - {/* 설정 모달 */} - {isSettingsOpen && setIsSettingsOpen(false)} />} + {isMobileMenuOpen && setIsMobileMenuOpen(false)} onOpenSettings={() => openModal(MODAL_TYPES.SettingsModal)} />} ); } diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx index 6fe19e3..5f86285 100644 --- a/src/components/layout/MobileMenu.tsx +++ b/src/components/layout/MobileMenu.tsx @@ -1,8 +1,11 @@ import { Link } from 'react-router-dom'; +import { MODAL_TYPES } from '../common/modalProvider'; + import ClearIcon from '@/assets/icons/Clear.svg?react'; import NotificationsIcon from '@/assets/icons/notifications_Blank.svg?react'; import SettingsIcon from '@/assets/icons/settings_Blank.svg?react'; +import useModalStore from '@/store/useModalStore'; interface IMobileMenuProps { onClose: () => void; @@ -10,6 +13,7 @@ interface IMobileMenuProps { } export default function MobileMenu({ onClose, onOpenSettings }: IMobileMenuProps) { + const { openModal } = useModalStore(); return ( <> {/* 검정 반투명 배경 오버레이 */} @@ -46,9 +50,14 @@ export default function MobileMenu({ onClose, onOpenSettings }: IMobileMenuProps {/* 알림, 설정 */}
- +