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: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
"@svgr/webpack": "^8.1.0",
"@tanstack/react-query": "^5.59.16",
"@tanstack/react-query-devtools": "^5.59.16",
"@types/event-source-polyfill": "^1.0.5",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"event-source-polyfill": "^1.0.31",
"framer-motion": "^11.11.11",
"json-server": "^1.0.0-beta.3",
"lottie-react": "^2.4.0",
Expand Down
23 changes: 23 additions & 0 deletions public/icons/NotificationIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Props } from '@/components/shared/Svg';
import { Svg } from '@/components/shared/Svg';

export default function NotificationIcon({
color = '#FFFFFF',
className,
...props
}: Props) {

return (
<Svg
width="24"
height="24"
viewBox="0 0 24 24"
fill={color}
className={className}
{...props}
>
<path d="M8.35179 20.2418C9.19288 21.311 10.5142 22 12 22C13.4858 22 14.8071 21.311 15.6482 20.2418C13.2264 20.57 10.7736 20.57 8.35179 20.2418Z" />
<path d="M18.7491 9V9.7041C18.7491 10.5491 18.9903 11.3752 19.4422 12.0782L20.5496 13.8012C21.5612 15.3749 20.789 17.5139 19.0296 18.0116C14.4273 19.3134 9.57274 19.3134 4.97036 18.0116C3.21105 17.5139 2.43882 15.3749 3.45036 13.8012L4.5578 12.0782C5.00972 11.3752 5.25087 10.5491 5.25087 9.7041V9C5.25087 5.13401 8.27256 2 12 2C15.7274 2 18.7491 5.13401 18.7491 9Z" />
</Svg>
);
}
23 changes: 23 additions & 0 deletions public/icons/XIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Props } from '@/components/shared/Svg';
import { Svg } from '@/components/shared/Svg';

export default function XIcon({
color = '#FFFFFF',
className,
...props
}: Props) {

return (
<Svg
width="24"
height="24"
viewBox="0 0 24 24"
fill={color}
className={className}
{...props}
>
<path d="M5 5L19.5 19.5" stroke="#3B3B3B" strokeWidth="1.8" strokeLinecap="round"/>
<path d="M19.5 5L5 19.5" stroke="#3B3B3B" strokeWidth="1.8" strokeLinecap="round"/>
</Svg>
);
}
19 changes: 19 additions & 0 deletions src/apis/deleteNotificationData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { instance } from '@/apis/api';
import type { GetNotificationResponse } from '@manchui-api';

export async function deleteNotificationData({ notificationId }: { notificationId: number }): Promise<GetNotificationResponse> {
const accessToken = localStorage.getItem('accessToken');

if (!accessToken) {
throw new Error('Access token이 없습니다.');
}

try {
const res = await instance.delete<GetNotificationResponse>(`/api/notifications/${notificationId}`);

return res.data;
} catch (e) {
console.error('deleteNotificationData 함수에서 오류 발생', e);
throw new Error('알림 데이터를 삭제하는데 실패했습니다.');
}
}
21 changes: 21 additions & 0 deletions src/apis/getNotificationData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { instance } from '@/apis/api';
import type { GetNotificationRequest, GetNotificationResponse } from '@manchui-api';

export async function getNotificationData(request: GetNotificationRequest): Promise<GetNotificationResponse> {
const { cursor, size, unreadOnly } = request;

const params = {
cursor,
size: size.toString(),
unreadOnly,
};

try {
const res = await instance.get<GetNotificationResponse>('/api/notifications', { params });

return res.data;
} catch (e) {
console.error('getNotificationData 함수에서 오류 발생', e);
throw new Error('알림 데이터를 불러오는데 실패했습니다.');
}
}
2 changes: 2 additions & 0 deletions src/components/Drawer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { logout } from '@/apis/user/postUser';
import Notification from '@/components/shared/GNB/Notification';
import { userStore } from '@/store/userStore';
import { useQueryClient } from '@tanstack/react-query';

Expand Down Expand Up @@ -63,6 +64,7 @@ export default function Drawer({ isLoggedIn, userData }: DrawerProps) {

return (
<div className="flex items-center">
<Notification />
<button type="button" onClick={toggleDrawer} className="relative flex h-10 w-10 items-center justify-center">
<div className={clsx('absolute transition-all duration-300 ease-in-out', isOpen ? 'rotate-90 opacity-0' : 'rotate-0 opacity-100')}>
<Image src="/icons/menu.svg" alt="메뉴" width={38} height={38} />
Expand Down
2 changes: 1 addition & 1 deletion src/components/main/CardSection/CardImage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function CardImage({ gathering }: CardImageProps) {
sizes="(max-width: 820px) 50vw, (max-width: 1240px) 50vw, 50vw"
className="transform rounded-t-2xl object-cover transition-transform duration-300 ease-in-out group-hover:scale-110 mobile:rounded-l-2xl mobile:rounded-tr-none tablet:rounded-t-2xl tablet:rounded-bl-none"
/>
<div className="absolute right-0 top-0 z-10">{showTag && <Tag Type="detail" Hour={dueDate.getHours() - 5} />}</div>
<div className="absolute right-0 top-0">{showTag && <Tag Type="detail" Hour={dueDate.getHours() - 5} />}</div>
{(currentUsers >= maxUsers || closed) && (
<div className="absolute inset-0 flex items-center justify-center rounded-t-2xl bg-black bg-opacity-70">
<span className={`text-full-response font-bold text-full ${bagelFatOne.className}`}>{closed ? 'CLOSED' : 'FULL'}</span>
Expand Down
5 changes: 3 additions & 2 deletions src/components/main/Carousel/PopularCategorySlide/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable tailwindcss/no-custom-classname */
import { useCallback } from 'react';
import Image from 'next/image';
import { useSetCategory } from '@/store/useFilterStore';
Expand Down Expand Up @@ -34,7 +35,7 @@ export default function PopularCategorySlide({ handleScrollToFilter }: PopularCa
<div className="relative z-10 mx-auto flex h-full max-w-[1200px] items-center">
{/* 텍스트와 카테고리 리스트 */}
<div className="flex flex-1 flex-col items-center justify-center">
<h1 className="text-center text-24-40-response font-bold text-[#FB1C49] drop-shadow-lg">🔥 인기 카테고리 🔥</h1>
<h1 className="text-lightred text-center text-24-40-response font-bold drop-shadow-lg">🔥 인기 카테고리 🔥</h1>
<h3 className="mt-2 text-center text-16-20-response font-semibold">실시간으로 모임수가 증가하고 있어요!</h3>
<div className="mt-10 flex justify-center gap-3 mobile:gap-6">
{categories.map(({ rank, category, imageSrc }) => (
Expand All @@ -43,7 +44,7 @@ export default function PopularCategorySlide({ handleScrollToFilter }: PopularCa
onClick={() => handleCategoryClick(category)}
className="relative flex cursor-pointer flex-col items-center justify-center gap-3 rounded-md transition-transform duration-300 hover:scale-105"
>
<div className="absolute left-0 top-0 flex h-7 w-14 items-center justify-center rounded-br-md rounded-tl-md bg-[#FB1C49] text-16-20-response font-semibold text-blue-800">
<div className="bg-lightred absolute left-0 top-0 flex h-7 w-14 items-center justify-center rounded-br-md rounded-tl-md text-16-20-response font-semibold text-blue-800">
{rank}위
</div>
<Image src={imageSrc} alt={category} width={400} height={400} className="h-full rounded-md object-cover" />
Expand Down
36 changes: 36 additions & 0 deletions src/components/shared/GNB/Notification/MobileUI/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* eslint-disable tailwindcss/no-custom-classname */
import { forwardRef } from 'react';
import ArrowBtn from 'public/icons/ArrowBtn';
import NotificationItem from '@/components/shared/GNB/Notification/NotificationItem';
import type { NotificationContent } from '@manchui-api';

export interface MobileUIProps {
isError: boolean;
isLoading: boolean;
notifications: NotificationContent[];
onDropClick: () => void;
}

export const MobileUI = forwardRef<HTMLDivElement, MobileUIProps>(({ notifications, isLoading, onDropClick, isError }, ref) => (
<div className="fixed inset-0 z-10 bg-white p-10">
<div className="mb-5 flex items-center">
<button type="button" onClick={onDropClick}>
<ArrowBtn direction="left" color="#fb1c49" className="size-8" />
</button>
<div className="flex w-full items-center justify-between gap-4">
<h1 className="text-2xl font-semibold leading-[32px] text-lightred">알림</h1>
{!isLoading && <span className="rounded-md bg-lightred px-4 py-2 text-xs font-medium text-white">{`${notifications.length}개`}</span>}
</div>
</div>

<div className="scrollbar-hide flex max-h-full flex-col divide-y divide-gray-50 overflow-y-auto">
{!isError && notifications.length === 0 && <h1 className="flex-center mt-16 font-medium">알림이 없습니다.</h1>}
{!isError && notifications.map((notification) => <NotificationItem key={notification.notificationId} data={notification} />)}
{isError && <h1 className="flex-center mt-16 font-medium">에러가 발생했습니다.</h1>}

<div ref={ref} className="h-10 w-full flex-shrink-0" />
</div>
</div>
));

MobileUI.displayName = 'MobileUI';
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* eslint-disable tailwindcss/no-custom-classname */
import { useRef, useState } from 'react';
import { deleteNotificationData } from '@/apis/deleteNotificationData';
import type { NotificationItemProps } from '@/components/shared/GNB/Notification/NotificationItem';
import useNotificationDrag from '@/hooks/useNotificationDrag';
import { formatTimeAgo } from '@/utils/dateUtils';
import { useMutation, useQueryClient } from '@tanstack/react-query';

export default function MobileUI({ data }: NotificationItemProps) {
const [isDeleting, setIsDeleting] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);

const queryClient = useQueryClient();

const mutation = useMutation({
mutationFn: deleteNotificationData,
onMutate: async ({ notificationId }) => {
// 알림 데이터에 대한 모든 퀴리요청을 취소하여 이전 서버 데이터가 낙관적 업데이트를 덮어쓰지 않도록 함 -> refetch 취소시킴
// 모든 관련 쿼리 요청 취소
await queryClient.cancelQueries({ queryKey: ['notification'] });

// 현재 캐시된 데이터 가져오기
// 이전 상태 저장(rollback을 사용하기 위해서)
const previousNotifications = queryClient.getQueryData<NotificationItemProps[]>(['notification']);

// 낙관적 업데이트: 삭제된 항목을 제외한 상태로 캐시를 갱신
queryClient.setQueryData(['notification'], (oldNotification: NotificationItemProps[] | undefined) => {
if (!oldNotification) return undefined;

return oldNotification.filter((notificaiton) => notificaiton.data.notificationId !== notificationId);
});

return { previousNotifications };
},
onError: (e) => {
console.log('알림 삭제 실패:', e);
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ['notification'] });
},
});

const handleDelete = () => {
setIsDeleting(true);
mutation.mutate({ notificationId: data.notificationId });
};

const { handleMouseDown, handleMouseLeave, handleMouseUp, handleMouseMove, handleTouchStart, handleTouchCancel, handleTouchEnd, handleTouchMove } =
useNotificationDrag({
ref: containerRef,
handleDelete,
});

return (
<div className={`relative overflow-x-hidden ${isDeleting && 'animate-fadeout'}`}>
<div
ref={containerRef}
className="flex min-h-[80px] w-full items-center justify-center gap-3 bg-white px-3 py-4 hover:bg-gray-50"
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onTouchStart={handleTouchStart}
onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
>
<p className="line-clamp-2 text-pretty text-[14px] font-medium leading-[24px]">{data.content}</p>

<span className="text-nowrap text-xs font-semibold text-lightred">{formatTimeAgo(String(data.createdAt))}</span>
</div>

<div className="flex-center pl-100 absolute inset-y-0 right-0 -z-10 rounded-r-[5px] bg-gradient-to-l from-lightred via-red-200 to-transparent pr-20 text-white">
삭제
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* eslint-disable tailwindcss/no-custom-classname */
import Link from 'next/link';
import XIcon from 'public/icons/XIcon';
import { deleteNotificationData } from '@/apis/deleteNotificationData';
import type { NotificationItemProps } from '@/components/shared/GNB/Notification/NotificationItem';
import { formatTimeAgo } from '@/utils/dateUtils';
import { useMutation, useQueryClient } from '@tanstack/react-query';

export default function TabletPCUI({ data }: NotificationItemProps) {
const queryClient = useQueryClient();

const mutation = useMutation({
mutationFn: deleteNotificationData,
onMutate: async ({ notificationId }) => {
// 알림 데이터에 대한 모든 퀴리요청을 취소하여 이전 서버 데이터가 낙관적 업데이트를 덮어쓰지 않도록 함 -> refetch 취소시킴
// 모든 관련 쿼리 요청 취소
await queryClient.cancelQueries({ queryKey: ['notification'] });

// 현재 캐시된 데이터 가져오기
// 이전 상태 저장(rollback을 사용하기 위해서)
const previousNotifications = queryClient.getQueryData<NotificationItemProps[]>(['notification']);

// 낙관적 업데이트: 삭제된 항목을 제외한 상태로 캐시를 갱신
queryClient.setQueryData(['notification'], (oldNotification: NotificationItemProps[] | undefined) => {
if (!oldNotification) return undefined;

return oldNotification.filter((notificaiton) => notificaiton.data.notificationId !== notificationId);
});

return { previousNotifications };
},
onError: (e) => {
console.log('알림 삭제 실패:', e);
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ['notification'] });
},
});

const handleDelete = (e: React.MouseEvent<SVGSVGElement>) => {
e.preventDefault();
mutation.mutate({ notificationId: data.notificationId });
};

return (
<Link href={`/detail/${data.gatheringId}`} className="relative flex min-h-[100px] w-full flex-col bg-white px-8 py-4 text-left hover:bg-gray-50">
<XIcon color="black" className="absolute right-2 top-2 hidden size-4 tablet:block" onClick={handleDelete} />
<p className="line-clamp-2 text-pretty text-[14px] font-medium leading-[24px]">{data.content}</p>
<span className="mt-auto text-xs font-semibold text-lightred">{formatTimeAgo(String(data.createdAt))}</span>
</Link>
);
}
20 changes: 20 additions & 0 deletions src/components/shared/GNB/Notification/NotificationItem/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import MobileUI from '@/components/shared/GNB/Notification/NotificationItem/MobileUI';
import TabletPCUI from '@/components/shared/GNB/Notification/NotificationItem/TabletPCUI';
import type { NotificationContent } from '@manchui-api';

export interface NotificationItemProps {
data: NotificationContent;
}

export default function NotificationItem({ data }: NotificationItemProps) {
return (
<>
<div className="hidden tablet:block">
<TabletPCUI data={data} />
</div>
<div className="block tablet:hidden">
<MobileUI data={data} />
</div>
</>
);
}
42 changes: 42 additions & 0 deletions src/components/shared/GNB/Notification/TabletPCUI/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { forwardRef } from 'react';
import * as m from 'framer-motion/m';
import ArrowBtn from 'public/icons/ArrowBtn';
import NotificationItem from '@/components/shared/GNB/Notification/NotificationItem';
import type { NotificationContent } from '@manchui-api';

export interface TabletPCUIProps {
isError: boolean;
isLoading: boolean;
notifications: NotificationContent[];
onDropClick: () => void;
}

export const TabletPCUI = forwardRef<HTMLDivElement, TabletPCUIProps>(({ notifications, isLoading, onDropClick, isError }, ref) => (
<m.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
className="absolute right-0 z-50 mt-2 min-h-40 w-[370px] rounded-md bg-background p-5 shadow-2xl"
>
<div className="mb-5 flex items-center">
<button type="button" onClick={onDropClick}>
<ArrowBtn direction="left" color="#fb1c49" className="size-8" />
</button>
<div className="flex w-full items-center justify-between gap-4">
<h1 className="text-xl font-semibold text-lightred">알림</h1>
{!isLoading && <span className="rounded-md bg-lightred px-2 py-1 text-xs font-medium text-white">{`${notifications.length}개`}</span>}
</div>
</div>

<div className="scrollbar-hide flex max-h-[300px] flex-col divide-y divide-gray-50 overflow-y-auto">
{!isError && notifications.length === 0 && <h1 className="flex-center mt-16 font-medium">알림이 없습니다.</h1>}
{!isError && notifications.map((notification) => <NotificationItem key={notification.notificationId} data={notification} />)}
{isError && <h1 className="flex-center mt-16 font-medium">에러가 발생했습니다.</h1>}

<div ref={ref} className="h-10 w-full flex-shrink-0" />
</div>
</m.div>
));

TabletPCUI.displayName = 'TabletPCUI';
Loading