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
4 changes: 4 additions & 0 deletions app/assets/icon/icon-bell.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions app/assets/icon/icon-notification-matching-gray.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions app/assets/icon/icon-notification-matching.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions app/assets/icon/icon-notification-proposal-gray.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions app/assets/icon/icon-notification-proposal.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions app/assets/icon/red-dot.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 1 addition & 3 deletions app/components/common/NavigateHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ export default function NavigationHeader({
</button>

<div className="flex-1 text-center">
<div
className={`text-Title1 font-semibold text-black ${titleClassName ?? ""}`}
>
<div className={`text-title1 text-semiblod ${titleClassName ?? ""}`}>
{title}
</div>
</div>
Expand Down
53 changes: 40 additions & 13 deletions app/components/layout/RealmatchHeader.tsx
Original file line number Diff line number Diff line change
@@ -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);
};
Expand Down Expand Up @@ -53,18 +56,42 @@ export default function RealMatchHeader({
) : null}
</div>

{/* Center: Logo + Text (정중앙 고정) */}
<button
type="button"
onClick={() => navigate("/")}
className="flex items-center justify-center gap-2"
>
<img
src={RealMatchLogo}
alt="Real Match"
draggable={false}
/>
</button>
{/* Center: Logo + Text */}
<div className="flex items-center justify-center">
<button
type="button"
onClick={() => navigate("/")}
className="flex items-center justify-center gap-2"
>
<img src={RealMatchLogo} alt="Real Match" draggable={false} />
</button>
</div>

<div className="flex items-center justify-end">
<button
type="button"
onClick={() => navigate("/notification")}
className="relative flex h-9 w-9 items-center justify-center rounded-full active:bg-black/5"
>
{/* 벨 아이콘 */}
<img
src={bellIcon}
alt="알림"
className="w-6 h-6 object-contain"
onError={(e) => console.error("벨 아이콘 로드 실패:", e)}
/>

{/* 알림이 있을 때만 빨간 점 노출 */}
{hasNotification === true && (
<img
src={redDot}
alt=""
className="absolute top-[8px] right-[8px] w-2 h-2"
onError={(e) => console.error("레드닷 로드 실패:", e)}
/>
)}
</button>
</div>
</div>
</header>
);
Expand Down
22 changes: 21 additions & 1 deletion app/routes/_main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -61,7 +63,25 @@ export default function MainLayout() {
>
<img alt="Real Match" draggable="false" src={Logo} />
</button>
<div />
<div className="flex items-center justify-end pr-4">
<button
type="button"
onClick={() => navigate("/notification")}
className="relative flex h-9 w-9 items-center justify-center rounded-full active:bg-black/5"
aria-label="알림"
>
<img
src={bellIcon}
alt="알림"
className="w-6 h-6 object-contain"
/>
<img
src={redDot}
alt=""
className="absolute top-[8px] right-[8px] w-2 h-2"
/>
</button>
</div>
</div>
</header>
)}
Expand Down
59 changes: 59 additions & 0 deletions app/routes/notification/components/NotificationItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
onClick={() => 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]"
}`}
>
<div className="relative shrink-0">
<img src={getIcon()} alt="icon" className="w-10 h-10" />
{!item.isRead && (
<div className="absolute top-0 right-0 w-2 h-2 bg-core-1 rounded-full border border-white" />
)}
</div>
<div className="flex flex-col gap-0.5 flex-1">
<p className={`text-title2 leading-[22px] ${!item.isRead ? "text-text-black font-medium" : "text-text-gray3 font-medium"}`}>
{item.body}
</p>
<span className="text-callout1 text-text-gray3">
{formatTime(item.createdAt)}
</span>
</div>
</div>
);
}
95 changes: 45 additions & 50 deletions app/routes/notification/notification-content.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={onClick}
className={`px-[10px] py-[6px] rounded-[8px] text-[14px] font-medium transition-all flex items-center gap-1.5 ${active
className={`px-[10px] py-[6px] rounded-[8px] text-title2 font-medium transition-all flex items-center gap-1.5 ${active
? "bg-core-1 text-white"
: "bg-white border border-[#E6E6F3] text-text-gray3"
}`}
>
{label}
{count !== undefined && count > 0 && (
<span className={`flex items-center justify-center rounded-full text-[10px] min-w-[18px] h-[18px] px-1 ${active ? "bg-white text-core-1" : "bg-core-1 text-white"
}`}>
{count}
</span>
)}
</button>
);
}

export default function NotificationContent() {
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [notifications, setNotifications] = useState<NotificationType[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"ALL" | "PROPOSAL" | "MATCHING">("ALL");
Expand All @@ -51,21 +46,21 @@

useEffect(() => {
fetchNotificationsData();
}, [activeTab]);

Check warning on line 49 in app/routes/notification/notification-content.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'fetchNotificationsData'. Either include it or remove the dependency array

Check warning on line 49 in app/routes/notification/notification-content.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'fetchNotificationsData'. Either include it or remove the dependency array

/*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;
Expand All @@ -85,16 +80,20 @@
}
};

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 (
<div className="h-screen-full bg-grad-auth ">
<div className="h-[60px]">
Expand All @@ -107,7 +106,6 @@
label="전체"
active={activeTab === "ALL"}
onClick={() => setActiveTab("ALL")}
count={activeTab === "ALL" ? unreadCount : undefined}
/>
<TabButton
label="받은 제안"
Expand All @@ -122,37 +120,34 @@
</div>
</div>

<div
className="overflow-y-auto"
style={{ height: `calc(100vh - 60px - 67px - 60px)` }}
>
<div className="overflow-y-auto pb-10" style={{ height: `calc(100vh - 127px)` }}>
{loading ? (
<div className="flex justify-center items-center h-40 text-text-gray3">로딩 중...</div>
) : notifications.length > 0 ? (
<div className="flex justify-center items-center h-40 text-text-gray3 text-callout1">로딩 중...</div>
) : sortedDates.length > 0 ? (
<div className="flex flex-col">
{notifications.map((item) => (
<div
key={item.id}
// 클릭 시 단건 읽기 함수 호출
onClick={() => 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"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className={`font-pretendard text-[14px] font-semibold leading-[20px] ${!item.isRead ? "text-core-1" : "text-text-gray3"
}`}>
{formatDate(item.createdAt)}
{sortedDates.map((date, index) => (
<div key={date} className="flex flex-col">
<div className="px-4 py-3 flex justify-between items-center">
<span className="text-title1 font-bold text-text-black">
{formatGroupDate(date)}
</span>
{!item.isRead && (
<div className="w-1.5 h-1.5 rounded-full bg-core-1" />
{/* 첫 번째 날짜 그룹(최신)에만 '전체 읽기' 노출 */}
{index === 0 && (
<button
onClick={handleReadAll}
className="text-callout1 text-core-1 active:scale-95 transition-all"
>
전체 읽기
</button>
)}
</div>
<p className={`text-[14px] leading-relaxed transition-colors ${!item.isRead
? "text-text-black font-semibold"
: "text-text-gray3 font-medium"
}`}>
{item.body}
</p>
{groupedNotifications[date].map((item) => (
<NotificationItem
key={item.id}
item={item}
onClick={handleReadNotification}
/>
))}
</div>
))}
</div>
Expand Down
Loading