From cafcf6314ab9c37d921a554260d6e805d614efec Mon Sep 17 00:00:00 2001 From: Rafi Athallah <65345768+rafiathallah3@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:27:16 +0700 Subject: [PATCH] feat: integrasi notifikasi backend dengan frontend --- package-lock.json | 114 ++++++++++++++++++- package.json | 1 + src/Pages/internify/NotificationList.tsx | 134 ++++++++++++++--------- src/services/NotificationService.ts | 96 ++++++++++++++++ src/types/notification.types.ts | 19 ++++ 5 files changed, 309 insertions(+), 55 deletions(-) create mode 100644 src/services/NotificationService.ts create mode 100644 src/types/notification.types.ts diff --git a/package-lock.json b/package-lock.json index c8e37cc..558df4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.6.0", "recharts": "^3.4.1", + "socket.io-client": "^4.8.3", "tailwindcss": "^4.1.6", "use-react-router-breadcrumbs": "^4.0.1" }, @@ -86,6 +87,7 @@ "integrity": "sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1399,6 +1401,12 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -1676,6 +1684,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.23.0.tgz", "integrity": "sha512-Cdfhd0Po1cKMYqHtyv/3XATXpf2Kjo8fuau/QJwrml0NpM18/XX9mAgp2NJ/QaiQ3vi8vDandg7RmZ5OrApglQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1975,6 +1984,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.23.0.tgz", "integrity": "sha512-PQFi8H+OrcaNXNGxbXSjJmZFh1wxiFMbUg25LjOX148d7i+21uWKy6avsr5rsBQNBAKIIMB6PQY61Lhv5r61uA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -2196,6 +2206,7 @@ "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -2206,6 +2217,7 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2268,6 +2280,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2510,6 +2523,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2634,6 +2648,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -2809,7 +2824,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-array": { "version": "3.2.4", @@ -2988,6 +3004,28 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", @@ -3135,6 +3173,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4369,6 +4408,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4537,6 +4577,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.1.tgz", "integrity": "sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -4566,6 +4607,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -4614,6 +4656,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.40.0.tgz", "integrity": "sha512-2G3svX0Cr1sJjkD/DYWSe3cfV5VPVTBOxI9XQEGWJDFEpsZb/gh4MV29ctv+OJx2RFX4BLt09i+6zaGM/ldkCw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -4652,6 +4695,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4677,6 +4721,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4741,12 +4786,14 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4802,6 +4849,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", "license": "MIT", + "peer": true, "dependencies": { "react-router": "7.15.1" }, @@ -4845,7 +4893,8 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4968,6 +5017,34 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5102,6 +5179,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5232,6 +5310,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5333,6 +5412,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 2e3fbd4..7dd0d56 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.6.0", "recharts": "^3.4.1", + "socket.io-client": "^4.8.3", "tailwindcss": "^4.1.6", "use-react-router-breadcrumbs": "^4.0.1" }, diff --git a/src/Pages/internify/NotificationList.tsx b/src/Pages/internify/NotificationList.tsx index c27067b..d2bf4d8 100644 --- a/src/Pages/internify/NotificationList.tsx +++ b/src/Pages/internify/NotificationList.tsx @@ -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([]); - 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 (
{/* Header */} @@ -61,20 +97,18 @@ export default function NotificationList() { {/* List Group Section */}
- + {/* Render Group TODAY */}
{notifications.filter(n => n.group === "TODAY").map((item) => ( -
{/* Icon Container */} -
+
{item.type === 'new-task' && } {item.type === 'achievement' && }
@@ -89,14 +123,10 @@ export default function NotificationList() { )}

{item.title}

- + {/* Custom description coloring logic */}

- {item.type === 'new-task' ? ( - <>Mentor Sarah Johnson has pushed a new task: User Persona Analysis - ) : item.type === 'achievement' ? ( - <>Your certificate for UI Engineering Phase 1 is now available for download! - ) : item.description} + {item.description}

{/* Conditional Actions */} @@ -132,10 +162,10 @@ export default function NotificationList() {

YESTERDAY

- + {notifications.filter(n => n.group === "YESTERDAY").map((item) => ( -
diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts new file mode 100644 index 0000000..6914699 --- /dev/null +++ b/src/services/NotificationService.ts @@ -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 => { + const res = await api.get("/notification-api"); + return res.data.data; +}; + +export const markAllAsRead = async (): Promise => { + await api.patch("/notification-api/read-all"); +}; + +export const markAsRead = async (id: number): Promise => { + 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, + }; +}; diff --git a/src/types/notification.types.ts b/src/types/notification.types.ts new file mode 100644 index 0000000..e8118a4 --- /dev/null +++ b/src/types/notification.types.ts @@ -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; +}