diff --git a/app/assets/icon/icon-bell.svg b/app/assets/icon/icon-bell.svg new file mode 100644 index 00000000..f4140fd8 --- /dev/null +++ b/app/assets/icon/icon-bell.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/icon/icon-notification-matching-gray.svg b/app/assets/icon/icon-notification-matching-gray.svg new file mode 100644 index 00000000..c4c2ac5e --- /dev/null +++ b/app/assets/icon/icon-notification-matching-gray.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/icon/icon-notification-matching.svg b/app/assets/icon/icon-notification-matching.svg new file mode 100644 index 00000000..acad5896 --- /dev/null +++ b/app/assets/icon/icon-notification-matching.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/icon/icon-notification-proposal-gray.svg b/app/assets/icon/icon-notification-proposal-gray.svg new file mode 100644 index 00000000..ba8bc93b --- /dev/null +++ b/app/assets/icon/icon-notification-proposal-gray.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/icon/icon-notification-proposal.svg b/app/assets/icon/icon-notification-proposal.svg new file mode 100644 index 00000000..a0346807 --- /dev/null +++ b/app/assets/icon/icon-notification-proposal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/icon/red-dot.svg b/app/assets/icon/red-dot.svg new file mode 100644 index 00000000..916cd55e --- /dev/null +++ b/app/assets/icon/red-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/common/NavigateHeader.tsx b/app/components/common/NavigateHeader.tsx index 68828b87..a9f70db8 100644 --- a/app/components/common/NavigateHeader.tsx +++ b/app/components/common/NavigateHeader.tsx @@ -41,9 +41,7 @@ export default function NavigationHeader({
-
+
{title}
diff --git a/app/components/layout/RealmatchHeader.tsx b/app/components/layout/RealmatchHeader.tsx index 5e0e37ad..45c72728 100644 --- a/app/components/layout/RealmatchHeader.tsx +++ b/app/components/layout/RealmatchHeader.tsx @@ -1,22 +1,25 @@ import { useNavigate } from "react-router"; import RealMatchLogo from "../../assets/logo/realmatch-logo-line.png" +import bellIcon from "../../assets/icon/icon-bell.svg"; +import redDot from "../../assets/icon/red-dot.svg"; type RealMatchHeaderProps = { /** 뒤로가기 버튼 노출 여부 */ title?: string; showBack?: boolean; onBack?: () => void; + hasNotification?: boolean; }; export default function RealMatchHeader({ showBack = true, onBack, + hasNotification = true, }: RealMatchHeaderProps) { const navigate = useNavigate(); const handleBack = () => { if (onBack) return onBack(); - // 브라우저 히스토리 뒤로가기 navigate(-1); }; @@ -53,18 +56,42 @@ export default function RealMatchHeader({ ) : null}
- {/* Center: Logo + Text (정중앙 고정) */} - + {/* Center: Logo + Text */} +
+ +
+ +
+ +
); diff --git a/app/routes/_main.tsx b/app/routes/_main.tsx index 480140b4..453c58b2 100644 --- a/app/routes/_main.tsx +++ b/app/routes/_main.tsx @@ -3,6 +3,8 @@ import { useState, useEffect, useRef } from "react"; import BottomTab from "../components/layout/BottomTab"; import { LayoutContext } from "./layout-context"; import Logo from "../assets/logo/realmatch-logo-line.png"; +import bellIcon from "../assets/icon/icon-bell.svg"; +import redDot from "../assets/icon/red-dot.svg"; import { tokenStorage } from "../lib/token"; export default function MainLayout() { @@ -61,7 +63,25 @@ export default function MainLayout() { > Real Match -
+
+ +
)} diff --git a/app/routes/notification/components/NotificationItem.tsx b/app/routes/notification/components/NotificationItem.tsx new file mode 100644 index 00000000..b52772d2 --- /dev/null +++ b/app/routes/notification/components/NotificationItem.tsx @@ -0,0 +1,59 @@ +// components/common/NotificationItem.tsx +import proposalIcon from "../../../assets/icon/icon-notification-proposal.svg"; +import proposalGrayIcon from "../../../assets/icon/icon-notification-proposal-gray.svg"; +import matchingIcon from "../../../assets/icon/icon-notification-matching.svg"; +import matchingGrayIcon from "../../../assets/icon/icon-notification-matching-gray.svg"; +import { type NotificationItem as NotificationType } from "../api/notification"; + +interface Props { + item: NotificationType; + onClick: (id: string, isRead: boolean) => void; +} + +export default function NotificationItem({ item, onClick }: Props) { + const getIcon = () => { + const isMatching = item.category === "MATCHING" || item.iconType === "MATCHING"; + + if (item.isRead) { + // 읽은 상태 + return isMatching ? matchingGrayIcon : proposalGrayIcon; + } + + // 안 읽은 상태 + return isMatching ? matchingIcon : proposalIcon; + }; + + // 시간 포맷 로직 + const formatTime = (dateString: string) => { + const date = new Date(dateString); + const ampm = date.getHours() >= 12 ? '오후' : '오전'; + let hours = date.getHours() % 12; + hours = hours ? hours : 12; + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${ampm} ${hours}시 ${minutes}분`; + }; + + return ( +
onClick(item.id, item.isRead)} + className={`px-4 py-4 flex gap-3 cursor-pointer transition-colors active:bg-gray-50 ${ + !item.isRead ? "bg-[#F5F6FF]" : "bg-[#F5F6FF]" + }`} + > +
+ icon + {!item.isRead && ( +
+ )} +
+
+

+ {item.body} +

+ + {formatTime(item.createdAt)} + +
+
+ ); +} \ No newline at end of file diff --git a/app/routes/notification/notification-content.tsx b/app/routes/notification/notification-content.tsx index 15f64258..4c014a1a 100644 --- a/app/routes/notification/notification-content.tsx +++ b/app/routes/notification/notification-content.tsx @@ -1,32 +1,27 @@ import { useState, useEffect } from "react"; -import { fetchNotifications, readNotification, type NotificationItem } from "./api/notification"; +import { fetchNotifications, readNotification, readAllNotifications, type NotificationItem as NotificationType } from "./api/notification"; import { useHideHeader } from "../../hooks/useHideHeader"; import NavigationHeader from "../../components/common/NavigateHeader"; import emptyImg from "./../../assets/empty.png"; import { useNavigate } from "react-router-dom"; +import NotificationItem from "./components/NotificationItem"; -function TabButton({ label, active, onClick, count }: { label: string; active: boolean; onClick: () => void; count?: number }) { +function TabButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void; count?: number }) { return ( ); } export default function NotificationContent() { - const [notifications, setNotifications] = useState([]); + const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState<"ALL" | "PROPOSAL" | "MATCHING">("ALL"); @@ -53,19 +48,19 @@ export default function NotificationContent() { fetchNotificationsData(); }, [activeTab]); - /*const handleReadAll = async () => { + // 전체 읽기 핸들러 + const handleReadAll = async () => { + if (unreadCount === 0) return; try { const data = await readAllNotifications(); if (data.isSuccess) { - setNotifications((prev) => - prev.map((n) => ({ ...n, isRead: true })) - ); + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); setUnreadCount(0); } } catch (error) { console.error("전체 읽기 처리 실패:", error); } - };*/ + }; const handleReadNotification = async (id: string, isRead: boolean) => { if (isRead) return; @@ -85,16 +80,20 @@ export default function NotificationContent() { } }; + const groupedNotifications = notifications.reduce((acc: { [key: string]: NotificationType[] }, item) => { + const date = item.createdAt.split('T')[0]; + if (!acc[date]) acc[date] = []; + acc[date].push(item); + return acc; + }, {}); - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString('ko-KR', { - year: '2-digit', - month: '2-digit', - day: '2-digit' - }).replace(/ /g, '').slice(0, -1); - }; + const sortedDates = Object.keys(groupedNotifications).sort((a, b) => b.localeCompare(a)); + const formatGroupDate = (dateStr: string) => { + const date = new Date(dateStr); + const days = ['일', '월', '화', '수', '목', '금', '토']; + return `${date.getFullYear().toString().slice(2)}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getDate().toString().padStart(2, '0')} (${days[date.getDay()]})`; + }; return (
@@ -107,7 +106,6 @@ export default function NotificationContent() { label="전체" active={activeTab === "ALL"} onClick={() => setActiveTab("ALL")} - count={activeTab === "ALL" ? unreadCount : undefined} />
-
+
{loading ? ( -
로딩 중...
- ) : notifications.length > 0 ? ( +
로딩 중...
+ ) : sortedDates.length > 0 ? (
- {notifications.map((item) => ( -
handleReadNotification(item.id, item.isRead)} - className={`p-4 border-b border-gray-50 flex flex-col gap-1 transition-colors active:bg-gray-50 ${!item.isRead ? "bg-blue-50/50" : "bg-white" - }`} - > -
- - {formatDate(item.createdAt)} + {sortedDates.map((date, index) => ( +
+
+ + {formatGroupDate(date)} - {!item.isRead && ( -
+ {/* 첫 번째 날짜 그룹(최신)에만 '전체 읽기' 노출 */} + {index === 0 && ( + )}
-

- {item.body} -

+ {groupedNotifications[date].map((item) => ( + + ))}
))}