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
7 changes: 7 additions & 0 deletions public/images/icons/ActiveBellIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 28 additions & 4 deletions src/components/primitives/global/Header/LoggingInGnb.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,42 @@
import Image from 'next/image';
import BellIcon from '@/public/images/icons/Bell.svg';
import ActiveBellIcon from '@/public/images/icons/ActiveBellIcon.svg';
import { useState } from 'react';
import NotificationModal from '../../notification/NotificationModal';
import UserMenuDropdown from './UserMenuDropdown';
import useCurrentUser from '@/src/hooks/useCurrentUser';
import { getNotifications } from '@/src/services/pages/notifications/api';
import { INotifications } from '@/src/types/notificationType';
import { useQuery } from '@tanstack/react-query';

export default function LoggingInGnb() {
const [isModalVisible, setIsModalVisible] = useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);

const userInfo = useCurrentUser();
const size = 10;

const { data: notifications } = useQuery<INotifications>({
queryKey: ['notifications', size],
queryFn: () => getNotifications({ size }),
staleTime: 1000 * 60,
});

const hasUnread = notifications?.notifications.some(
(n) =>
new Date().getTime() - new Date(n.updatedAt).getTime() <
24 * 60 * 60 * 1000
);

return (
<div className='relative'>
<div className='flex items-center gap-5'>
<div className='relative'>
<button onClick={() => setIsModalVisible(!isModalVisible)}>
<BellIcon />
<button
onClick={() => setIsModalVisible(!isModalVisible)}
className='align-middle'
>
{hasUnread ? <ActiveBellIcon /> : <BellIcon />}
</button>

{isModalVisible && (
Expand All @@ -27,10 +47,14 @@ export default function LoggingInGnb() {
/>

<div
className='absolute top-full left-1/2 -translate-x-1/2 mt-2 z-20 rounded-2xl shadow-[0_4px_24px_0_#9CB4CA33]'
className='fixed inset-0 flex items-start justify-center md:absolute md:inset-auto md:top-full md:left-1/2 md:-translate-x-1/2 md:mt-4 top-10 mt-2 z-20 rounded-2xl shadow-[0_4px_24px_0_#9CB4CA33]
'
onClick={(e) => e.stopPropagation()}
>
<NotificationModal setVisible={setIsModalVisible} />
<NotificationModal
setVisible={setIsModalVisible}
notifications={notifications ?? null}
/>
</div>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function UserMenuDropdown({
};

return (
<div className='flex flex-col justify-around items-center w-32 py-2 bg-white border border-[#dfdfdf] rounded-lg'>
<div className='flex flex-col justify-around items-center w-32 py-2 bg-white border border-gray-50 rounded-lg'>
<button
type='button'
className='px-4 py-2 text-14 lg:text-16 font-medium text-left'
Expand Down
153 changes: 143 additions & 10 deletions src/components/primitives/notification/NotificationModal.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,154 @@
'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';
import CloseIcon from '@/public/images/icons/DeleteIcon.svg';
import {
deleteNotificationById,
getNotifications,
} from '@/src/services/pages/notifications/api';
import { INotification, INotifications } from '@/src/types/notificationType';
import { cn } from '@/src/utils/cn';
import { dateToCalendarDate } from '@/src/utils/dateParser';
import {
getNotificationType,
isRecent,
parseContent,
} from '@/src/utils/notifications';
import { useCallback, useEffect, useState } from 'react';
import useInfiniteScroll from '@/src/hooks/useInfiniteScroll';

interface Props {
setVisible: (state: boolean) => void;
notifications: INotifications | null;
}

export default function NotificationModal({
setVisible,
}: {
setVisible: (state: boolean) => void;
}) {
notifications,
}: Props) {
const queryClient = useQueryClient();
const [items, setItems] = useState<INotification[]>([]);
const [cursorId, setCursorId] = useState<number | null>(null);
const [hasMore, setHasMore] = useState(true);

useEffect(() => {
if (notifications) {
setItems(notifications.notifications);
setCursorId(notifications.cursorId);
setHasMore(!!notifications.cursorId);
}
}, [notifications]);

const fetchMore = useCallback(async () => {
if (!cursorId) return;

const res: INotifications = await getNotifications({
size: 5,
cursorId,
});

setItems((prev) => [...prev, ...res.notifications]);
setCursorId(res.cursorId);
setHasMore(!!res.cursorId);
}, [cursorId]);

const { loadMoreRef } = useInfiniteScroll(fetchMore, hasMore);

const deleteMutation = useMutation({
mutationFn: (id: number) => deleteNotificationById(id),
onSuccess: (_, id) => {
queryClient.setQueryData<INotifications | null>(
['notifications', 10],
(old) => {
if (!old) return old;
return {
...old,
notifications: old.notifications.filter((n) => n.id !== id),
totalCount: old.totalCount - 1,
};
}
);
},
});

const handleDelete = (id: number) => deleteMutation.mutate(id);

return (
<div className='w-[231px] h-48 pt-4 pb-2 bg-white rounded-2xl'>
<div className='px-6 pb-3.5 flex justify-between border-b border-gray-100'>
<h2 className='text-16 font-bold'>알림 6개</h2>
<button onClick={() => setVisible(false)}>
<CloseIcon />
</button>
<div className='w-full max-w-[327px] md:w-[235px] md:max-w-none h-auto pt-4 bg-white rounded-2xl shadow'>
<div className='px-5 pb-3.5 flex justify-between items-center border-b border-gray-100'>
<h2 className='text-16 font-bold'>
알림 {notifications?.totalCount ?? 0}개
</h2>
<div className='flex justify-between'>
<button onClick={() => setVisible(false)}>
<CloseIcon />
</button>
</div>
</div>

<div className='max-h-[350px] rounded-b-2xl overflow-y-auto thin-scrollbar'>
{items.length ? (
<>
{items.map((n) => {
const notificationType = getNotificationType(n.content);
const { relative } = dateToCalendarDate(new Date(n.createdAt));
const formatted = parseContent(n.content);

return (
<article
key={n.id}
className={cn(
'px-5 py-4 h-34 border-b border-gray-100 last:border-b-0 last:rounded-b-2xl',
isRecent(n.createdAt, n.updatedAt)
? 'bg-primary-100'
: 'bg-gray-25'
)}
>
<div className='flex flex-col h-full gap-2'>
<div className='flex justify-between'>
<div className='flex items-center gap-2'>
<h3 className='text-14 font-bold'>
예약{' '}
{notificationType === 'RESERVATION_APPROVED'
? '승인'
: '거절'}
</h3>
<span className='text-12 font-medium text-gray-400'>
{relative}
</span>
</div>
<button
className='text-13 font-medium text-red-400'
onClick={() => handleDelete(n.id)}
>
삭제
</button>
</div>
<div className='flex flex-col'>
<p
className='text-14-body font-medium text-gray-800 leading-[180%] align-middle'
dangerouslySetInnerHTML={{ __html: formatted }}
/>
</div>
</div>
</article>
);
})}
{hasMore && (
<div
ref={loadMoreRef}
className='h-10 flex items-center justify-center text-gray-400'
>
불러오는 중...
</div>
)}
</>
) : (
<article className='px-5 py-4 h-34 flex items-center justify-center text-gray-400'>
아직 알람이 없습니다
</article>
)}
</div>
<article>므아지경</article>
</div>
);
}
21 changes: 21 additions & 0 deletions src/services/pages/notifications/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { INotifications } from '@/src/types/notificationType';
import { apiClient } from '../../primitives/apiClient';

interface Params {
cursorId?: number;
size?: number;
}

export async function getNotifications(params?: Params) {
const { data } = await apiClient.get<INotifications>('/my-notifications', {
params,
});
return data;
}

export async function deleteNotificationById(notificationId: number) {
const { data } = await apiClient.delete(
`/my-notifications/${notificationId}`
);
return data;
}
21 changes: 21 additions & 0 deletions src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,25 @@
opacity: 0;
}
}

@layer base {
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}

.thin-scrollbar::-webkit-scrollbar {
width: 6px;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 9999px;
}
.thin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
}
}
21 changes: 12 additions & 9 deletions src/types/notificationType.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
export interface INotification {
cursorId: number;
notification: {
id: number;
teamId: string;
userId: number;
createdAt: string;
updatedAt: string;
deletedAt: string;
}[];
id: number;
teamId: string;
userId: number;
content: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}

export interface INotifications {
cursorId: number | null;
notifications: INotification[];
totalCount: number;
}
4 changes: 3 additions & 1 deletion src/utils/dateParser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { format, getDay } from 'date-fns';
import { format, formatDistanceToNow, getDay } from 'date-fns';
import { ko } from 'date-fns/locale';

export function dateToCalendarDate(date: Date) {
const calendarDate = {
Expand All @@ -7,6 +8,7 @@ export function dateToCalendarDate(date: Date) {
month: format(date, 'MM'),
day: format(date, 'dd'),
yoil: getDay(date),
relative: formatDistanceToNow(date, { addSuffix: true, locale: ko }),
};

return calendarDate;
Expand Down
32 changes: 32 additions & 0 deletions src/utils/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export const isRecent = (createdAt: string, updatedAt?: string) => {
const baseTime = updatedAt || createdAt;
return (
new Date().getTime() - new Date(baseTime).getTime() < 24 * 60 * 60 * 1000
);
};

export type NotificationType =
| 'RESERVATION_APPROVED'
| 'RESERVATION_REJECTED'
| 'OTHER';

export const getNotificationType = (content: string): NotificationType => {
if (content.includes('승인')) return 'RESERVATION_APPROVED';
if (content.includes('거절')) return 'RESERVATION_REJECTED';
return 'OTHER';
};

export const parseContent = (content: string) => {
let formatted = content.replace(/(\))\s*(예약)/, '$1<br />$2');
if (content.includes('승인'))
formatted = formatted.replace(
'승인',
`<span class="text-primary-500">승인</span>`
);
if (content.includes('거절'))
formatted = formatted.replace(
'거절',
`<span class="text-red-500">거절</span>`
);
return formatted;
};