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..ff4fef2 --- /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).catch((error) => console.error('Failed to open window:', error))); +}); diff --git a/src/api/alarm/alarm.ts b/src/api/alarm/alarm.ts new file mode 100644 index 0000000..f1b9750 --- /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, + 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..c6f0a9e --- /dev/null +++ b/src/components/alarmModal/alarm.tsx @@ -0,0 +1,14 @@ +import type { TAlarm } from '@/types/alarm/alarm'; + +import ChevronForward from '@/assets/icons/default_arrows/chevron_forward.svg?react'; + +function Alarm({ title }: TAlarm) { + return ( +
+
{title}
+ +
+ ); +} + +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..20011d7 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 c0373ca..d8cee30 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 {/* 알림, 설정 */}
- +