Skip to content

Commit

Permalink
Merge pull request #150 from ALLREVA/ARV-207-feat/alarm
Browse files Browse the repository at this point in the history
[ARV-207] Web Push 알림 및 PWA 구현
  • Loading branch information
zelkovaria authored Feb 18, 2025
2 parents e211855 + 88f59cc commit 159ca94
Show file tree
Hide file tree
Showing 14 changed files with 859 additions and 2 deletions.
7 changes: 6 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Allreva</title>
<link rel="apple-touch-icon" href="touch-icon-iphone.png" />
<link rel="apple-touch-icon" sizes="152x152" href="touch-icon-ipad.png" />
<link rel="apple-touch-icon" sizes="167x167" href="touch-icon-ipad-retina.png" />
<link rel="apple-touch-icon" sizes="180x180" href="touch-icon-iphone-retina.png" />
<link href="/manifest.json" rel="manifest" />
<title>ALLREVA</title>
</head>
<body>
<div id="root"></div>
Expand Down
14 changes: 14 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"short_name": "ALLREVA",
"name": "ALLREVA: 차량 대절 서비스",
"icons": [
{ "src": "/favicon.png", "sizes": "96x96", "type": "image/x-icon" },
{ "src": "/logo192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/logo512x512.png", "sizes": "512x512", "type": "image/png" }
],
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#000000",
"background_color": "#ffffff"
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.7",
"dayjs": "^1.11.13",
"firebase": "^11.2.0",
"framer-motion": "^11.11.17",
"immer": "^10.1.1",
"lodash-es": "^4.17.21",
Expand Down
32 changes: 32 additions & 0 deletions public/firebase-messaging-sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
self.addEventListener('install', function (e) {
console.log('fcm sw install..');
self.skipWaiting();
});

self.addEventListener('activate', function (e) {
console.log('fcm sw activate..');
});

self.addEventListener('push', function (e) {
console.log('push: ', e.data.json());
if (!e.data.json()) return;

const resultData = e.data.json().notification;
const notificationTitle = resultData.title;
const notificationOptions = {
body: resultData.body,
icon: resultData.image,
tag: resultData.tag,
...resultData,
};
console.log('push: ', { resultData, notificationTitle, notificationOptions });

self.registration.showNotification(notificationTitle, notificationOptions);
});

self.addEventListener('notificationclick', function (event) {
console.log('notification click');
const url = '/';
event.notification.close();
event.waitUntil(clients.openWindow(url));
});
Binary file added public/logo192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/logo512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import styled from '@emotion/styled';
import { Suspense, useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';

import { requestPermission } from '../src/firebase-messaging-sw';

import { endPoint } from 'constants/endPoint';
import { useScreenSize } from 'hooks';
import { router } from 'routes/routes';
Expand All @@ -22,6 +24,8 @@ function App() {

const newToken: string = response.headers['authorization'];
authStore.getState().setToken(newToken);

void requestPermission();
} catch (error) {
console.error('새로고침시 data 요청 에러', error);
}
Expand Down
5 changes: 5 additions & 0 deletions src/constants/endPoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,9 @@ export const endPoint = {
CREATE_CONCERT_RECORD: '/diaries',
UPDATE_CONCERT_RECORD: '/diaries',
DELETE_CONCERT_RECORD: (diaryId: string) => `/diaries/${diaryId}`,

// Alarm
GET_NOTIFICATION_TOKEN: '/notifications/device-token',
GET_NOTIFICATIONS: '/notifications',
PATCH_NOTIFICATION_READ: '/notifications/read',
};
62 changes: 62 additions & 0 deletions src/firebase-messaging-sw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, onMessage } from 'firebase/messaging';

import { endPoint } from 'constants/endPoint';
import { tokenAxios } from 'utils/axios';

const firebaseConfig = {
apiKey: import.meta.env.VITE_API_KEY,
authDomain: 'allreva-86be8.firebaseapp.com',
projectId: 'allreva-86be8',
storageBucket: 'allreva-86be8.firebasestorage.app',
messagingSenderId: import.meta.env.VITE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_APP_ID,
measurementId: import.meta.env.VITE_MEASUREMENT_ID,
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);

if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/firebase-messaging-sw.js')
.then((registration) => {
console.log('Service Worker registered with scope:', registration.scope);
})
.catch((err) => {
console.error('Service Worker registration failed:', err);
});
}

export async function requestPermission() {
console.log('권한 요청 중...');

const permission = await Notification.requestPermission();
if (permission === 'denied') {
console.log('알림 권한이 허용되지 않았습니다');
return;
}

console.log('알림 권한이 허용되었습니다');

const token = await getToken(messaging, {
vapidKey: import.meta.env.VITE_VAPID_KEY,
});

if (token) {
console.log('token: ', token);

try {
await tokenAxios.post(endPoint.GET_NOTIFICATION_TOKEN, { deviceToken: token });
} catch (error) {
console.error('토큰 전송에 실패했습니다', error);
}
} else {
console.log('token을 얻을 수 없습니다');
}

onMessage(messaging, (payload) => {
console.log('메시지가 도착했습니다.', payload);
});
}
54 changes: 54 additions & 0 deletions src/pages/notification/Notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import styled from '@emotion/styled';
import { useQuery } from '@tanstack/react-query';

import NotificationItem from './components/NotificationItem';

import { endPoint } from 'constants/endPoint';
import { tokenAxios } from 'utils';

export interface Notification {
createdAt: string;
updatedAt: string;
deletedAt: string;
id: number;
title: string;
message: string;
recipientId: number;
read: boolean;
}

interface NotificationResult {
timeStamp: string;
code: string;
message: string;
result: Notification[];
}

const Notification = () => {
const getNotification = async () => {
const response = await tokenAxios.get(endPoint.GET_NOTIFICATIONS);

return response.data.result;
};

const { data } = useQuery<Notification[]>({
queryKey: ['notifications'],
queryFn: () => getNotification(),
});
console.log(data);
return (
<NotificationContainer>
{data?.map((item) => <NotificationItem key={item.id} notification={item} />)}
</NotificationContainer>
);
};

const NotificationContainer = styled.div`
display: flex;
flex-direction: column;
padding: 2.4rem;
gap: 1.6rem;
height: 100%;
`;

export default Notification;
96 changes: 96 additions & 0 deletions src/pages/notification/components/NotificationItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import styled from '@emotion/styled';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { TbBell } from 'react-icons/tb';

import type { Notification } from '../Notification';

import { endPoint } from 'constants/endPoint';
import { BodyRegularText, ChipText, TitleText2 } from 'styles/Typography';
import { formatFromNowDate, tokenAxios } from 'utils';

interface NotificationItemProps {
notification: Notification;
}

const NotificationItem = ({ notification }: NotificationItemProps) => {
const queryClient = useQueryClient();

const { mutate: patchRead } = useMutation({
mutationFn: (notificationId: number) =>
tokenAxios.patch(`${endPoint.PATCH_NOTIFICATION_READ}`, {
id: notificationId,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});

const handlePatchRead = () => {
if (!notification.read) {
patchRead(notification.id);
console.log(notification.id);
}
};

return (
<NotificationItemContainer onClick={handlePatchRead}>
<NotificationIcon>
<TbBellIcon size={28} />
</NotificationIcon>
<ContentWrapper>
<NotificationTitle>
<TitleText2>{notification.title}</TitleText2>
{!notification.read && <NotificationDot />}
</NotificationTitle>
<BodyRegularText>{notification.message}</BodyRegularText>
<ChipText>{formatFromNowDate(notification.createdAt)}</ChipText>
</ContentWrapper>
</NotificationItemContainer>
);
};

const NotificationItemContainer = styled.div`
display: flex;
align-items: center;
gap: 1.6rem;
min-height: 14rem;
border-radius: 16px;
background-color: ${({ theme }) => theme.colors.dark[700]};
padding: 1.2rem 2.2rem;
cursor: pointer;
`;

const NotificationIcon = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 5rem;
height: 5rem;
border-radius: 14px;
background-color: ${({ theme }) => theme.colors.dark[100]};
`;

const TbBellIcon = styled(TbBell)`
fill: ${({ theme }) => theme.colors.dark[300]};
color: ${({ theme }) => theme.colors.dark[300]};
`;

const NotificationTitle = styled.div`
display: flex;
gap: 0.6rem;
align-items: center;
`;

const NotificationDot = styled.div`
width: 0.8rem;
height: 0.8rem;
border-radius: 100%;
background-color: ${({ theme }) => theme.colors.red};
`;

const ContentWrapper = styled.div`
display: flex;
flex-direction: column;
`;

export default NotificationItem;
6 changes: 6 additions & 0 deletions src/routes/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AuthHeaderLayout, AuthTitleHeaderLayout, PublicOnlyLayout } from 'layou
import SearchLayout from 'layout/SearchLayout';
import Callback from 'pages/callback/Callback';
import ConcertHallsList from 'pages/concertHallsList/ConcertHallsList';
import Notification from 'pages/notification/Notification';
import Search from 'pages/search/Search';
import SearchMoreConcerts from 'pages/searchMore/SearchMoreConcerts';
import SearchMoreRents from 'pages/searchMore/SearchMoreRents';
Expand Down Expand Up @@ -205,6 +206,11 @@ export const router = createBrowserRouter([
element: <CreateConcertRecord type="edit" />,
handle: { title: '공연 기록 수정' },
},
{
path: '/notifications',
element: <Notification />,
handle: { title: '알림' },
},
],
},
],
Expand Down
7 changes: 7 additions & 0 deletions src/utils/dateUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/ko';

dayjs.extend(relativeTime);
dayjs.locale('ko');

// D-Day 계산
Expand Down Expand Up @@ -72,3 +74,8 @@ export const formatDotDate = (dateString: string) => {
const date = dayjs(dateString);
return `${date.format('YYYY.MM.DD')}`;
};

// YYYY.MM.DD -> (오늘 날짜 기준) nd일전 형식으로 변환
export const formatFromNowDate = (date: string) => {
return dayjs(date).fromNow();
};
Loading

0 comments on commit 159ca94

Please sign in to comment.