Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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));
});
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: 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 itmes-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 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
39 changes: 39 additions & 0 deletions src/components/modal/alarmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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';

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

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

useEffect(() => {
if (inView) {
if (!isFetching && hasNextPage) {
fetchNextPage();
}
}
}, [inView, isFetching, hasNextPage, fetchNextPage]);
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.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;
33 changes: 33 additions & 0 deletions src/firebase/firebase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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 () => {
const token = await getToken(messaging, {
vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
});
return token;
};

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(cursor, size).queryKey,
queryFn: ({ pageParam = cursor }) => getAlarm({ cursor: pageParam, size }),
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
4 changes: 4 additions & 0 deletions src/queryKey/queryKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ import { createQueryKeys } from '@lukemorales/query-key-factory';
export const regionKeys = createQueryKeys('region', {
search: (keyword: string) => [keyword],
});

export const alarmKeys = createQueryKeys('alarm', {
getAlarm: (size, cursor) => [size, cursor],
});
Loading