Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}'],
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions public/firebase-messaging-sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/// <reference lib="webworker" />
/* 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)));
});
18 changes: 18 additions & 0 deletions src/api/alarm/alarm.ts
Original file line number Diff line number Diff line change
@@ -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<TResponseGetAlarm> => {
const { data } = await axiosInstance.get('/api/v1/alarms', {
params: {
size,
cursor,
},
});
return data;
};

export const postDeviceToken = async ({ deviceToken }: TRequestPostDeviceToken): Promise<TResponsePostDeviceToken> => {
const { data } = await axiosInstance.post('/api/v1/alarms/device-tokens', { deviceToken });
return data;
};
14 changes: 14 additions & 0 deletions src/components/alarmModal/alarm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-between w-full py-[24px] border-b-[2px] border-b-default-gray-400">
<div className="text-default-gray-800 text-[22px] sm:w-[500px] w-[200px] whitespace-nowrap overflow-hidden text-ellipsis">{title}</div>
<ChevronForward className="self-center" />
</div>
);
}

export default Alarm;
4 changes: 2 additions & 2 deletions src/components/common/graySvgButton.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
9 changes: 6 additions & 3 deletions src/components/common/modalProvider.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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() {
Expand Down
16 changes: 7 additions & 9 deletions src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,10 +57,10 @@ export default function Header({ mode = 'full' }: IHeaderProps) {

{/* 아이콘 버튼 */}
<div className="hidden lg:flex items-center space-x-5">
<Link to="/">
<button type="button" onClick={() => openModal(MODAL_TYPES.AlarmModal)}>
<NotificationsIcon className="w-5 h-5" fill="none" stroke="#000000" />
</Link>
<button type="button" onClick={() => setIsSettingsOpen(true)}>
</button>
<button type="button" onClick={() => openModal(MODAL_TYPES.SettingsModal)}>
<SettingsIcon className="w-5 h-5" fill="none" stroke="#000000" />
</button>
</div>
Expand All @@ -83,10 +84,7 @@ export default function Header({ mode = 'full' }: IHeaderProps) {
</div>

{/* 모바일 메뉴 */}
{isMobileMenuOpen && <MobileMenu onClose={() => setIsMobileMenuOpen(false)} onOpenSettings={() => setIsSettingsOpen(true)} />}

{/* 설정 모달 */}
{isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />}
{isMobileMenuOpen && <MobileMenu onClose={() => setIsMobileMenuOpen(false)} onOpenSettings={() => openModal(MODAL_TYPES.SettingsModal)} />}
</header>
);
}
13 changes: 11 additions & 2 deletions src/components/layout/MobileMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
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;
onOpenSettings: () => void;
}

export default function MobileMenu({ onClose, onOpenSettings }: IMobileMenuProps) {
const { openModal } = useModalStore();
return (
<>
{/* 배경 오버레이 */}
Expand Down Expand Up @@ -46,9 +50,14 @@ export default function MobileMenu({ onClose, onOpenSettings }: IMobileMenuProps

{/* 알림, 설정 */}
<div className="flex gap-5 mt-10">
<Link to="/" onClick={onClose}>
<button
onClick={() => {
openModal(MODAL_TYPES.AlarmModal);
onClose();
}}
>
<NotificationsIcon className="w-5 h-5" fill="none" stroke="#000000" />
</Link>
</button>
<button
type="button"
onClick={() => {
Expand Down
49 changes: 49 additions & 0 deletions src/components/modal/alarmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import ClipLoader from 'react-spinners/ClipLoader';

import { useGetAlarm } from '@/hooks/alarm/useGetAlarm';

import Alarm from '../alarmModal/alarm';
import Modal from '../common/modal';

import ErrorComponent from '@/pages/common/Error';

type TAlarmModalProps = {
onClose: () => void;
};

function AlarmModal({ onClose }: TAlarmModalProps) {
const { data, fetchNextPage, isFetching, hasNextPage, error } = useGetAlarm({ size: 5 });
const { ref, inView } = useInView({
threshold: 0,
});

useEffect(() => {
if (inView) {
if (!isFetching && hasNextPage) {
fetchNextPage();
}
}
}, [inView, isFetching, hasNextPage, fetchNextPage]);

if (error) {
return <ErrorComponent />;
}

return (
<Modal title="알림" onClose={onClose} position="main">
<div className="mt-[5px] flex flex-col items-center justify-center sm:w-[600px] w-[300px] px-[28px] max-h-[300px] overflow-y-scroll">
{(!data?.pages?.length || data.pages.every((page) => page.result.alarmList.length === 0)) && (
<div className="text-center flex text-default-gray-700 font-heading3 py-8 h-fit">아직 알림이 없습니다</div>
)}
{data?.pages.map((alarmList) => alarmList.result.alarmList.map((alarm) => <Alarm key={alarm.id} {...alarm} />))}
<div ref={ref} className="flex w-full justify-center mt-[10px] h-[1px]">
{isFetching && hasNextPage && <ClipLoader />}
</div>
</div>
</Modal>
);
}

export default AlarmModal;
41 changes: 41 additions & 0 deletions src/firebase/firebase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// src/firebase/firebase.ts
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken } from 'firebase/messaging';

const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_PROJECT_ID,
storageBucket: import.meta.env.VITE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_MESSAGE_SENDER_ID,
appId: import.meta.env.VITE_APP_ID,
measurementId: import.meta.env.VITE_MEASUREMENT_ID,
};

const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);

export const generateToken = async () => {
try {
const token = await getToken(messaging, {
vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
});
if (!token) {
console.warn('FCM 토큰 생성에 실패했습니다. 알림 권한을 확인해주세요.');
}
return token;
} catch (error) {
console.error('FCM 토큰 생성 중 오류 발생:', error);
return null;
}
};

export const registerServiceWorker = async () => {
try {
if ('serviceWorker' in navigator) {
await navigator.serviceWorker.register('/firebase-messaging-sw.js');
}
} catch (err) {
console.error('Service Worker registration failed:', err);
}
};
39 changes: 39 additions & 0 deletions src/hooks/alarm/useDeviceToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// src/hooks/alarm/useDeviceToken.ts
import { useEffect } from 'react';
import { isSupported } from 'firebase/messaging';

import { postDeviceToken } from '@/api/alarm/alarm'; // 서버에 FCM 토큰 전송하는 API 함수
import { generateToken, registerServiceWorker } from '@/firebase/firebase';

export const useDeviceToken = () => {
useEffect(() => {
const setupFCM = async () => {
if (!(await isSupported())) {
console.warn('FCM은 현재 브라우저에서 지원되지 않습니다.');
return;
}

await registerServiceWorker();
const token = await generateToken();

if (token) {
try {
await postDeviceToken({ deviceToken: token }); // 서버에 전송
} catch (err) {
console.error('디바이스 토큰 서버 전송 실패:', err);
}
}
};

const handleClick = () => {
setupFCM();
window.removeEventListener('click', handleClick);
};

window.addEventListener('click', handleClick);

return () => {
window.removeEventListener('click', handleClick);
};
}, []);
};
15 changes: 15 additions & 0 deletions src/hooks/alarm/useGetAlarm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useInfiniteQuery } from '@tanstack/react-query';

import type { TRequestGetAlarm } from '@/types/alarm/alarm';

import { getAlarm } from '@/api/alarm/alarm';
import { alarmKeys } from '@/queryKey/queryKey';

export const useGetAlarm = ({ cursor, size }: TRequestGetAlarm) => {
return useInfiniteQuery({
queryKey: alarmKeys.getAlarm(size ?? 5, cursor).queryKey,
queryFn: ({ pageParam = cursor }) => getAlarm({ cursor: pageParam, size: size ?? 5 }),
initialPageParam: cursor,
getNextPageParam: (lastPage) => lastPage.result.cursor ?? undefined,
});
};
4 changes: 4 additions & 0 deletions src/pages/home/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useDeviceToken } from '@/hooks/alarm/useDeviceToken';

import Banner from '@/components/home/banner';
import DateCourseStore from '@/components/home/dateCourseStore';
import DateLocation from '@/components/home/dateLocation';
Expand All @@ -8,6 +10,8 @@ import Level from '@/components/home/level';
import WordCloudCard from '@/components/home/wordCloud';

function Home() {
useDeviceToken();

return (
<div className="bg-default-gray-100 min-h-screen mb-[40px]">
<Banner />
Expand Down
Loading