feat: integrasi notifikasi backend dengan frontend
This commit is contained in:
@@ -1,45 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Check, Download, Info } from "lucide-react";
|
||||
import { LuFileSpreadsheet } from "react-icons/lu"; // Alternatif icon task dari react-icons
|
||||
import { io } from "socket.io-client";
|
||||
import * as NotificationService from "../../services/NotificationService";
|
||||
import type { UINotification, BackendNotification } from "../../types/notification.types";
|
||||
|
||||
const initialNotifications = [
|
||||
{
|
||||
id: 1,
|
||||
type: "new-task",
|
||||
title: "New Task Assigned",
|
||||
time: "Just now",
|
||||
isNew: true,
|
||||
group: "TODAY",
|
||||
description: "Mentor Sarah Johnson has pushed a new task: User Persona Analysis"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "achievement",
|
||||
title: "Certificate Ready",
|
||||
time: "2 hours ago",
|
||||
isNew: false,
|
||||
group: "TODAY",
|
||||
description: "Your certificate for UI Engineering Phase 1 is now available for download!"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "system",
|
||||
title: "System Maintenance",
|
||||
time: "2 days ago",
|
||||
isNew: false,
|
||||
group: "YESTERDAY",
|
||||
description: "Internify will undergo scheduled maintenance this Sunday from 02:00 AM to 04:00 AM GMT+7."
|
||||
}
|
||||
];
|
||||
const getCookieToken = () => {
|
||||
return document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("token="))
|
||||
?.split("=")[1];
|
||||
};
|
||||
|
||||
export default function NotificationList() {
|
||||
const [notifications, setNotifications] = useState(initialNotifications);
|
||||
const [notifications, setNotifications] = useState<UINotification[]>([]);
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, isNew: false })));
|
||||
alert("All notifications marked as read!");
|
||||
useEffect(() => {
|
||||
const loadNotifications = async () => {
|
||||
try {
|
||||
const data = await NotificationService.getNotifications();
|
||||
const mapped = data.map(NotificationService.mapNotificationToUI);
|
||||
setNotifications(mapped);
|
||||
} catch (err) {
|
||||
console.error("Failed to load notifications:", err);
|
||||
}
|
||||
};
|
||||
|
||||
loadNotifications();
|
||||
|
||||
const token = getCookieToken();
|
||||
if (!token) return;
|
||||
|
||||
const socketUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:9000";
|
||||
const socket = io(socketUrl, {
|
||||
auth: { token },
|
||||
transports: ["websocket"]
|
||||
});
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log("WebSocket connected for notifications");
|
||||
});
|
||||
|
||||
socket.on("notification", (notif: BackendNotification) => {
|
||||
console.log("New realtime notification received:", notif);
|
||||
const mapped = NotificationService.mapNotificationToUI(notif);
|
||||
setNotifications(prev => {
|
||||
if (prev.some(p => p.id === mapped.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [mapped, ...prev];
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log("WebSocket disconnected for notifications");
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await NotificationService.markAllAsRead();
|
||||
setNotifications(prev => prev.map(n => ({ ...n, isNew: false })));
|
||||
alert("All notifications marked as read!");
|
||||
} catch (err) {
|
||||
console.error("Failed to mark all as read:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (false) {
|
||||
(handleMarkAllRead as any)();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{/* Header */}
|
||||
@@ -61,20 +97,18 @@ export default function NotificationList() {
|
||||
|
||||
{/* List Group Section */}
|
||||
<div className="space-y-6">
|
||||
|
||||
|
||||
{/* Render Group TODAY */}
|
||||
<div className="space-y-3">
|
||||
{notifications.filter(n => n.group === "TODAY").map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`relative flex items-start gap-4 p-5 bg-white border border-gray-100 rounded-xl shadow-[0_2px_10px_rgba(0,0,0,0.01)] ${
|
||||
item.type === 'new-task' ? 'border-l-4 border-l-[#B30000]' : ''
|
||||
}`}
|
||||
<div
|
||||
key={item.id}
|
||||
className={`relative flex items-start gap-4 p-5 bg-white border border-gray-100 rounded-xl shadow-[0_2px_10px_rgba(0,0,0,0.01)] ${item.type === 'new-task' ? 'border-l-4 border-l-[#B30000]' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Icon Container */}
|
||||
<div className={`p-2.5 rounded-xl ${
|
||||
item.type === 'new-task' ? 'bg-red-50 text-red-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
<div className={`p-2.5 rounded-xl ${item.type === 'new-task' ? 'bg-red-50 text-red-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{item.type === 'new-task' && <LuFileSpreadsheet className="w-5 h-5" />}
|
||||
{item.type === 'achievement' && <Check className="w-5 h-5 stroke-[2.5]" />}
|
||||
</div>
|
||||
@@ -89,14 +123,10 @@ export default function NotificationList() {
|
||||
)}
|
||||
<h3 className="text-sm font-bold text-gray-900">{item.title}</h3>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Custom description coloring logic */}
|
||||
<p className="text-xs text-gray-500 font-medium leading-relaxed max-w-2xl">
|
||||
{item.type === 'new-task' ? (
|
||||
<>Mentor <span className="font-semibold text-gray-900">Sarah Johnson</span> has pushed a new task: <span className="italic font-semibold text-gray-800">User Persona Analysis</span></>
|
||||
) : item.type === 'achievement' ? (
|
||||
<>Your certificate for <span className="font-semibold text-gray-900">UI Engineering Phase 1</span> is now available for download!</>
|
||||
) : item.description}
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
{/* Conditional Actions */}
|
||||
@@ -132,10 +162,10 @@ export default function NotificationList() {
|
||||
<h2 className="text-[10px] font-bold tracking-widest text-gray-400 uppercase">
|
||||
YESTERDAY
|
||||
</h2>
|
||||
|
||||
|
||||
{notifications.filter(n => n.group === "YESTERDAY").map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
<div
|
||||
key={item.id}
|
||||
className="relative flex items-start gap-4 p-5 bg-gray-50 border border-gray-100 rounded-xl"
|
||||
>
|
||||
<div className="p-2.5 bg-gray-200 text-gray-500 rounded-xl">
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// services/NotificationService.ts
|
||||
import api from "../lib/api";
|
||||
import type { BackendNotification, UINotification } from "../types/notification.types";
|
||||
|
||||
export const getNotifications = async (): Promise<BackendNotification[]> => {
|
||||
const res = await api.get("/notification-api");
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const markAllAsRead = async (): Promise<void> => {
|
||||
await api.patch("/notification-api/read-all");
|
||||
};
|
||||
|
||||
export const markAsRead = async (id: number): Promise<void> => {
|
||||
await api.patch(`/notification-api/${id}/read`);
|
||||
};
|
||||
|
||||
export const mapType = (title: string): string => {
|
||||
const t = title.toLowerCase();
|
||||
if (t.includes("task") || t.includes("tugas")) return "new-task";
|
||||
if (t.includes("certificate") || t.includes("sertifikat") || t.includes("achievement")) return "achievement";
|
||||
return "system";
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (dateStr: string): string => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDays = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMs < 0 || diffSec < 5) {
|
||||
return "Just now";
|
||||
}
|
||||
if (diffSec < 60) {
|
||||
return `${diffSec} seconds ago`;
|
||||
}
|
||||
if (diffMin < 60) {
|
||||
return `${diffMin} ${diffMin === 1 ? 'minute' : 'minutes'} ago`;
|
||||
}
|
||||
if (diffHr < 24) {
|
||||
return `${diffHr} ${diffHr === 1 ? 'hour' : 'hours'} ago`;
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
|
||||
// Fallback to absolute date
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
} catch (error) {
|
||||
return "Some time ago";
|
||||
}
|
||||
};
|
||||
|
||||
export const mapGroup = (dateStr: string): "TODAY" | "YESTERDAY" => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
|
||||
// Grouping: TODAY or YESTERDAY
|
||||
// TODAY means today's calendar day
|
||||
const isToday =
|
||||
date.getDate() === now.getDate() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getFullYear() === now.getFullYear();
|
||||
|
||||
if (isToday) {
|
||||
return "TODAY";
|
||||
}
|
||||
return "YESTERDAY";
|
||||
} catch (e) {
|
||||
return "YESTERDAY";
|
||||
}
|
||||
};
|
||||
|
||||
export const mapNotificationToUI = (notif: BackendNotification): UINotification => {
|
||||
return {
|
||||
id: notif.id,
|
||||
type: mapType(notif.title),
|
||||
title: notif.title,
|
||||
time: formatRelativeTime(notif.created_at),
|
||||
isNew: !notif.is_read,
|
||||
group: mapGroup(notif.created_at),
|
||||
description: notif.message,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface BackendNotification {
|
||||
id: number;
|
||||
id_user: number | null;
|
||||
id_admin: number | null;
|
||||
title: string;
|
||||
message: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UINotification {
|
||||
id: number;
|
||||
type: string; // "new-task" | "achievement" | "system"
|
||||
title: string;
|
||||
time: string;
|
||||
isNew: boolean;
|
||||
group: "TODAY" | "YESTERDAY";
|
||||
description: string;
|
||||
}
|
||||
Reference in New Issue
Block a user