From 335ce3b00a11161c8dd709f4ad5b27bf515e4f4d Mon Sep 17 00:00:00 2001 From: Vincent You <113566592+Vinceyou1@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:16:23 -0500 Subject: [PATCH 1/3] notification rework --- app/(protected)/notifications/page.tsx | 209 +++++++++++++++++++++++++ app/actions/notifications.ts | 26 +++ components/Header.tsx | 149 +++++++++++++++++- prisma/schema.prisma | 18 +++ 4 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 app/(protected)/notifications/page.tsx create mode 100644 app/actions/notifications.ts diff --git a/app/(protected)/notifications/page.tsx b/app/(protected)/notifications/page.tsx new file mode 100644 index 0000000..0886268 --- /dev/null +++ b/app/(protected)/notifications/page.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { + getNotifications, + markNotificationAsSeen, +} from "@/app/actions/notifications"; +import { Notification } from "@prisma/client"; +import { Circle, Dot, TrendingUp } from "lucide-react"; +import { useSession } from "next-auth/react"; +import { useState, useTransition, useRef, useCallback, useEffect } from "react"; + +type PendingDeal = { + id: string; + title: string; + ebitda: number; + status: string; +}; + +type WebSocketMessage = { + type: string; + productId?: string; + status?: string; + userId?: string; +}; + +export default function Notifications() { + const userSession = useSession(); + + const userId = userSession.data ? userSession.data.user.id : undefined; + + // extract this logic to some sort of hook eventually + const [completedDeals, setCompletedDeals] = useState([]); + const [pendingDeals, setPendingDeals] = useState([]); + const [wsConnected, setWsConnected] = useState(false); + const [isPending, startTransition] = useTransition(); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const retryDelayRef = useRef(1000); + + const fetchNotifications = useCallback(async () => { + if (!userId) return; + const notifications = await getNotifications(userId); + setPendingDeals( + notifications.filter((notif) => notif.status === "PENDING"), + ); + setCompletedDeals( + notifications.filter((notif) => notif.status === "COMPLETED"), + ); + // TODO: implement graceful error displaying + }, [userId]); + + const fetchAndTransition = useCallback(() => { + startTransition(() => { + fetchNotifications(); + }); + }, [userId]); + + useEffect(() => { + fetchAndTransition(); + }, [fetchAndTransition]); + + const formatEbitda = (ebitda: number) => { + if (ebitda >= 1000000) { + return `$${(ebitda / 1000000).toFixed(1)}M`; + } else if (ebitda >= 1000) { + return `$${(ebitda / 1000).toFixed(1)}K`; + } + return `$${ebitda.toLocaleString()}`; + }; + + const connectWebSocket = useCallback(() => { + const url = process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://localhost:8080"; + + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + setWsConnected(true); + ws.send(JSON.stringify({ type: "register", userId })); + retryDelayRef.current = 1000; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }; + ws.onmessage = (e) => { + try { + const msg: WebSocketMessage = JSON.parse(e.data); + if (msg.type === "new_screen_call") fetchAndTransition(); + if (msg.type === "problem_done" && msg.productId) fetchAndTransition(); + } catch {} + }; + const scheduleReconnect = () => { + if (reconnectTimeoutRef.current) return; + const delay = Math.min(retryDelayRef.current, 10000); + reconnectTimeoutRef.current = setTimeout(() => { + reconnectTimeoutRef.current = null; + connectWebSocket(); + }, delay); + retryDelayRef.current = Math.min(delay * 2, 10000); + }; + ws.onclose = () => { + setWsConnected(false); + scheduleReconnect(); + }; + ws.onerror = () => { + setWsConnected(false); + scheduleReconnect(); + }; + }, [userId, fetchAndTransition]); + + useEffect(() => { + if (!userId) return; + connectWebSocket(); + return () => { + wsRef.current?.close(); + wsRef.current = null; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + retryDelayRef.current = 1000; + }; + }, [userId, connectWebSocket]); + + if (!userSession.data) + return
Loading...
; + + return ( +
+
+

Pending - {pendingDeals.length}

+ {/*
+ {pendingDeals.map((deal) => ( +
+
+
+
+ {deal.dealTitle || `Deal #${deal.dealId}`} +
+

+ ID: {deal.id} +

+
+
+
+ ))} +
*/} +
+ {pendingDeals.map((deal) => ( +
+
+
+
+ {deal.dealTitle || `Deal #${deal.id}`} +
+

+ ID: {deal.dealId} +

+
+
+
+ ))} +
+
+ +
+ ); +} diff --git a/app/actions/notifications.ts b/app/actions/notifications.ts new file mode 100644 index 0000000..4deb64a --- /dev/null +++ b/app/actions/notifications.ts @@ -0,0 +1,26 @@ +"use server"; + +import prismaDB from "@/lib/prisma"; + +export async function getNotifications(userId: string) { + const notifications = await prismaDB.notification.findMany({ + where: { + userId: userId, + }, + orderBy: { + createdAt: 'desc' + } + }); + return notifications; +} + +export async function markNotificationAsSeen(notificationId: string) { + await prismaDB.notification.update({ + where: { + id: notificationId, + }, + data: { + seen: true, + }, + }); +} diff --git a/components/Header.tsx b/components/Header.tsx index da08969..351f572 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -3,7 +3,13 @@ import clsx from "clsx"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import React, { useEffect, useState } from "react"; +import React, { + useCallback, + useEffect, + useRef, + useState, + useTransition, +} from "react"; import { MdMenu, MdClose } from "react-icons/md"; import { FiPlus, @@ -20,7 +26,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { ChevronDown, Lock } from "lucide-react"; +import { BellIcon, ChevronDown, Lock } from "lucide-react"; import { ModeToggle } from "./mode-toggle"; import { Session } from "next-auth"; import { signOut, useSession } from "next-auth/react"; @@ -28,8 +34,9 @@ import { IconType } from "react-icons/lib"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import NotificationPopover from "./NotificationPopover"; import { FaScrewdriver } from "react-icons/fa"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; type HeaderProps = { className?: string; @@ -93,8 +100,7 @@ const Header = ({ className, session }: HeaderProps) => {
- - + {session ? : }
@@ -111,6 +117,139 @@ const Header = ({ className, session }: HeaderProps) => { export default Header; +type PendingDeal = { + id: string; + title: string; + ebitda: number; + status: string; +}; + +type WebSocketMessage = { + type: string; + productId?: string; + status?: string; + userId?: string; +}; + +function NotificationLink({ userId }: { userId: string }) { + const [deals, setDeals] = useState([]); + const [wsConnected, setWsConnected] = useState(false); + const [isPending, startTransition] = useTransition(); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const retryDelayRef = useRef(1000); + const fetchDeals = useCallback(async () => { + try { + const res = await fetch("/api/deals/pending"); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const data: PendingDeal[] = await res.json(); + console.log("📊 Fetched deals:", data); + setDeals(data); + } catch (error) { + console.error("❌ Error fetching deals:", error); + } + }, []); + + const fetchAndTransition = useCallback(() => { + startTransition(() => { + fetchDeals(); + }); + }, [fetchDeals]); + + useEffect(() => { + fetchAndTransition(); + }, [fetchAndTransition]); + + const formatEbitda = (ebitda: number) => { + if (ebitda >= 1000000) { + return `$${(ebitda / 1000000).toFixed(1)}M`; + } else if (ebitda >= 1000) { + return `$${(ebitda / 1000).toFixed(1)}K`; + } + return `$${ebitda.toLocaleString()}`; + }; + + const connectWebSocket = useCallback(() => { + const url = process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://localhost:8080"; + + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + setWsConnected(true); + ws.send(JSON.stringify({ type: "register", userId })); + retryDelayRef.current = 1000; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }; + ws.onmessage = (e) => { + try { + const msg: WebSocketMessage = JSON.parse(e.data); + if (msg.type === "new_screen_call") fetchAndTransition(); + if (msg.type === "problem_done" && msg.productId) fetchAndTransition(); + } catch {} + }; + const scheduleReconnect = () => { + if (reconnectTimeoutRef.current) return; + const delay = Math.min(retryDelayRef.current, 10000); + reconnectTimeoutRef.current = setTimeout(() => { + reconnectTimeoutRef.current = null; + connectWebSocket(); + }, delay); + retryDelayRef.current = Math.min(delay * 2, 10000); + }; + ws.onclose = () => { + setWsConnected(false); + scheduleReconnect(); + }; + ws.onerror = () => { + setWsConnected(false); + scheduleReconnect(); + }; + }, [userId, fetchAndTransition]); + + useEffect(() => { + if (!userId) return; + connectWebSocket(); + return () => { + wsRef.current?.close(); + wsRef.current = null; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + retryDelayRef.current = 1000; + }; + }, [userId, connectWebSocket]); + + return ( + + + + ); +} + function NameLogo() { return ( Date: Sun, 14 Sep 2025 16:26:37 -0500 Subject: [PATCH 2/3] notifications hook + better seen --- app/(protected)/notifications/page.tsx | 133 ++----------- components/Header.tsx | 108 ++-------- components/NotificationPopover.tsx | 264 ------------------------- hooks/use-notifications.ts | 104 ++++++++++ 4 files changed, 134 insertions(+), 475 deletions(-) delete mode 100644 components/NotificationPopover.tsx create mode 100644 hooks/use-notifications.ts diff --git a/app/(protected)/notifications/page.tsx b/app/(protected)/notifications/page.tsx index 0886268..81962fe 100644 --- a/app/(protected)/notifications/page.tsx +++ b/app/(protected)/notifications/page.tsx @@ -4,6 +4,8 @@ import { getNotifications, markNotificationAsSeen, } from "@/app/actions/notifications"; +import useNotifications from "@/hooks/use-notifications"; +import { cn } from "@/lib/utils"; import { Notification } from "@prisma/client"; import { Circle, Dot, TrendingUp } from "lucide-react"; import { useSession } from "next-auth/react"; @@ -24,106 +26,15 @@ type WebSocketMessage = { }; export default function Notifications() { - const userSession = useSession(); - - const userId = userSession.data ? userSession.data.user.id : undefined; - - // extract this logic to some sort of hook eventually - const [completedDeals, setCompletedDeals] = useState([]); - const [pendingDeals, setPendingDeals] = useState([]); - const [wsConnected, setWsConnected] = useState(false); - const [isPending, startTransition] = useTransition(); - const wsRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const retryDelayRef = useRef(1000); - - const fetchNotifications = useCallback(async () => { - if (!userId) return; - const notifications = await getNotifications(userId); - setPendingDeals( - notifications.filter((notif) => notif.status === "PENDING"), - ); - setCompletedDeals( - notifications.filter((notif) => notif.status === "COMPLETED"), - ); - // TODO: implement graceful error displaying - }, [userId]); - - const fetchAndTransition = useCallback(() => { - startTransition(() => { - fetchNotifications(); - }); - }, [userId]); - - useEffect(() => { - fetchAndTransition(); - }, [fetchAndTransition]); - - const formatEbitda = (ebitda: number) => { - if (ebitda >= 1000000) { - return `$${(ebitda / 1000000).toFixed(1)}M`; - } else if (ebitda >= 1000) { - return `$${(ebitda / 1000).toFixed(1)}K`; - } - return `$${ebitda.toLocaleString()}`; - }; - - const connectWebSocket = useCallback(() => { - const url = process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://localhost:8080"; - - const ws = new WebSocket(url); - wsRef.current = ws; - - ws.onopen = () => { - setWsConnected(true); - ws.send(JSON.stringify({ type: "register", userId })); - retryDelayRef.current = 1000; - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - }; - ws.onmessage = (e) => { - try { - const msg: WebSocketMessage = JSON.parse(e.data); - if (msg.type === "new_screen_call") fetchAndTransition(); - if (msg.type === "problem_done" && msg.productId) fetchAndTransition(); - } catch {} - }; - const scheduleReconnect = () => { - if (reconnectTimeoutRef.current) return; - const delay = Math.min(retryDelayRef.current, 10000); - reconnectTimeoutRef.current = setTimeout(() => { - reconnectTimeoutRef.current = null; - connectWebSocket(); - }, delay); - retryDelayRef.current = Math.min(delay * 2, 10000); - }; - ws.onclose = () => { - setWsConnected(false); - scheduleReconnect(); - }; - ws.onerror = () => { - setWsConnected(false); - scheduleReconnect(); - }; - }, [userId, fetchAndTransition]); - - useEffect(() => { - if (!userId) return; - connectWebSocket(); - return () => { - wsRef.current?.close(); - wsRef.current = null; - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - retryDelayRef.current = 1000; - }; - }, [userId, connectWebSocket]); + const { notifications, wsConnected } = useNotifications(); + const pendingDeals = notifications.filter( + (notif) => notif.status === "PENDING", + ); + const completedDeals = notifications.filter( + (notif) => notif.status === "COMPLETED", + ); - if (!userSession.data) + if (!wsConnected) return
Loading...
; return ( @@ -171,23 +82,18 @@ export default function Notifications() { diff --git a/components/Header.tsx b/components/Header.tsx index 351f572..1824a97 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -37,6 +37,7 @@ import { Label } from "@/components/ui/label"; import { FaScrewdriver } from "react-icons/fa"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; +import useNotifications from "@/hooks/use-notifications"; type HeaderProps = { className?: string; @@ -100,7 +101,7 @@ const Header = ({ className, session }: HeaderProps) => {
- + {session ? : }
@@ -131,112 +132,25 @@ type WebSocketMessage = { userId?: string; }; -function NotificationLink({ userId }: { userId: string }) { - const [deals, setDeals] = useState([]); - const [wsConnected, setWsConnected] = useState(false); - const [isPending, startTransition] = useTransition(); - const wsRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const retryDelayRef = useRef(1000); - const fetchDeals = useCallback(async () => { - try { - const res = await fetch("/api/deals/pending"); - if (!res.ok) { - throw new Error(`HTTP error! status: ${res.status}`); - } - const data: PendingDeal[] = await res.json(); - console.log("📊 Fetched deals:", data); - setDeals(data); - } catch (error) { - console.error("❌ Error fetching deals:", error); - } - }, []); - - const fetchAndTransition = useCallback(() => { - startTransition(() => { - fetchDeals(); - }); - }, [fetchDeals]); - - useEffect(() => { - fetchAndTransition(); - }, [fetchAndTransition]); - - const formatEbitda = (ebitda: number) => { - if (ebitda >= 1000000) { - return `$${(ebitda / 1000000).toFixed(1)}M`; - } else if (ebitda >= 1000) { - return `$${(ebitda / 1000).toFixed(1)}K`; - } - return `$${ebitda.toLocaleString()}`; - }; - - const connectWebSocket = useCallback(() => { - const url = process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://localhost:8080"; - - const ws = new WebSocket(url); - wsRef.current = ws; - - ws.onopen = () => { - setWsConnected(true); - ws.send(JSON.stringify({ type: "register", userId })); - retryDelayRef.current = 1000; - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - }; - ws.onmessage = (e) => { - try { - const msg: WebSocketMessage = JSON.parse(e.data); - if (msg.type === "new_screen_call") fetchAndTransition(); - if (msg.type === "problem_done" && msg.productId) fetchAndTransition(); - } catch {} - }; - const scheduleReconnect = () => { - if (reconnectTimeoutRef.current) return; - const delay = Math.min(retryDelayRef.current, 10000); - reconnectTimeoutRef.current = setTimeout(() => { - reconnectTimeoutRef.current = null; - connectWebSocket(); - }, delay); - retryDelayRef.current = Math.min(delay * 2, 10000); - }; - ws.onclose = () => { - setWsConnected(false); - scheduleReconnect(); - }; - ws.onerror = () => { - setWsConnected(false); - scheduleReconnect(); - }; - }, [userId, fetchAndTransition]); - - useEffect(() => { - if (!userId) return; - connectWebSocket(); - return () => { - wsRef.current?.close(); - wsRef.current = null; - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - retryDelayRef.current = 1000; - }; - }, [userId, connectWebSocket]); +function NotificationLink() { + const { notifications, wsConnected } = useNotifications(); + const newCount = notifications.filter( + (notif) => + notif.status === "PENDING" || + (notif.status === "COMPLETED" && notif.seen === false), + ).length; return ( - - -
-
-
- - Your Deals Queue -
-
- {deals.length > 0 && ( - - {deals.length} pending - - )} -
-
-
-
- - - {isPending && ( -
-
-
- Loading deals... -
-
- )} - - {!wsConnected && !isPending && ( -
-
-
-
-

- Connection lost -

-

- Attempting to reconnect... -

-
- )} - - {deals.length === 0 && !isPending && wsConnected && ( -
- -

- No pending deals -

-

- Your deals will appear here when they're ready -

-
- )} - - {deals.length > 0 && !isPending && ( -
- {deals.map((deal) => ( -
-
-
-
- {deal.title || `Deal #${deal.id}`} -
-

- ID: {deal.id} -

-
-
-
- - - {formatEbitda(deal.ebitda)} - -
- - {deal.status} - -
-
-
- ))} -
- )} -
- - -
- ); -}; - -export default NotificationPopover; diff --git a/hooks/use-notifications.ts b/hooks/use-notifications.ts new file mode 100644 index 0000000..fefadcc --- /dev/null +++ b/hooks/use-notifications.ts @@ -0,0 +1,104 @@ +"use client"; + +import { getNotifications } from "@/app/actions/notifications"; +import { Notification } from "@prisma/client"; +import { useSession } from "next-auth/react"; +import { useState, useTransition, useRef, useCallback, useEffect } from "react"; + +type WebSocketMessage = { + type: string; + productId?: string; + status?: string; + userId?: string; +}; + +export default function useNotifications() { + const userSession = useSession(); + + const userId = userSession.data ? userSession.data.user.id : undefined; + + // extract this logic to some sort of hook eventually + const [notifications, setNotifications] = useState([]); + const [wsConnected, setWsConnected] = useState(false); + const [isPending, startTransition] = useTransition(); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const retryDelayRef = useRef(1000); + + const fetchNotifications = useCallback(async () => { + if (!userId) return; + const notifications = await getNotifications(userId); + setNotifications(notifications); + // TODO: implement graceful error displaying + }, [userId]); + + const fetchAndTransition = useCallback(() => { + startTransition(() => { + fetchNotifications(); + }); + }, [userId]); + + useEffect(() => { + fetchAndTransition(); + }, [fetchAndTransition]); + + const connectWebSocket = useCallback(() => { + const url = process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://localhost:8080"; + + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + setWsConnected(true); + ws.send(JSON.stringify({ type: "register", userId })); + retryDelayRef.current = 1000; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }; + ws.onmessage = (e) => { + try { + const msg: WebSocketMessage = JSON.parse(e.data); + if (msg.type === "new_screen_call") fetchAndTransition(); + if (msg.type === "problem_done" && msg.productId) fetchAndTransition(); + } catch {} + }; + const scheduleReconnect = () => { + if (reconnectTimeoutRef.current) return; + const delay = Math.min(retryDelayRef.current, 10000); + reconnectTimeoutRef.current = setTimeout(() => { + reconnectTimeoutRef.current = null; + connectWebSocket(); + }, delay); + retryDelayRef.current = Math.min(delay * 2, 10000); + }; + ws.onclose = () => { + setWsConnected(false); + scheduleReconnect(); + }; + ws.onerror = () => { + setWsConnected(false); + scheduleReconnect(); + }; + }, [userId, fetchAndTransition]); + + useEffect(() => { + if (!userId) return; + connectWebSocket(); + return () => { + wsRef.current?.close(); + wsRef.current = null; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + retryDelayRef.current = 1000; + }; + }, [userId, connectWebSocket]); + + + // maybe use better "loading" variable? + // using wsConnected mainly as a sign of if userSession is fetched + return {notifications, wsConnected} +} From e80b73ee2d84a5c92f7819cd09321dd02f679fec Mon Sep 17 00:00:00 2001 From: Vincent You <113566592+Vinceyou1@users.noreply.github.com> Date: Wed, 17 Sep 2025 12:14:59 -0500 Subject: [PATCH 3/3] notification table --- app/(protected)/notifications/columns.tsx | 52 +++++++ app/(protected)/notifications/data-table.tsx | 154 +++++++++++++++++++ app/(protected)/notifications/page.tsx | 8 +- prisma/schema.prisma | 2 - 4 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 app/(protected)/notifications/columns.tsx create mode 100644 app/(protected)/notifications/data-table.tsx diff --git a/app/(protected)/notifications/columns.tsx b/app/(protected)/notifications/columns.tsx new file mode 100644 index 0000000..2860d0f --- /dev/null +++ b/app/(protected)/notifications/columns.tsx @@ -0,0 +1,52 @@ +import { Notification } from "@prisma/client"; +import { ColumnDef } from "@tanstack/react-table"; + +function timeSince(date: Date) { + var seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000); + + var interval = seconds / 31536000; + + if (interval > 1) { + return Math.floor(interval) + " years"; + } + interval = seconds / 2592000; + if (interval > 1) { + return Math.floor(interval) + " months"; + } + interval = seconds / 86400; + if (interval > 1) { + return Math.floor(interval) + " days"; + } + interval = seconds / 3600; + if (interval > 1) { + return Math.floor(interval) + " hours"; + } + interval = seconds / 60; + if (interval > 1) { + return Math.floor(interval) + " minutes"; + } + return Math.floor(seconds) + " seconds"; +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "dealTitle", + header: "Deal Title", + cell: ({ row }) => ( +
{row.original.dealTitle} + ), + // enableSorting: true, + enableColumnFilter: true, + filterFn: 'includesString', + enableHiding: false, + }, + { + accessorKey: "createdAt", + header: "Queued", + cell: ({ row }) => ( + + {timeSince(row.original.createdAt)} ago + + ), + }, +]; diff --git a/app/(protected)/notifications/data-table.tsx b/app/(protected)/notifications/data-table.tsx new file mode 100644 index 0000000..8eb0ec2 --- /dev/null +++ b/app/(protected)/notifications/data-table.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; +import * as React from "react"; +// Intersection Observer hook +function useVisibleRows(rowCount: number, onVisible: (maxIndex: number) => void) { + const rowRefs = React.useRef<(HTMLTableRowElement | null)[]>([]); + React.useEffect(() => { + const observer = new window.IntersectionObserver( + (entries) => { + const visible: number[] = []; + entries.forEach((entry) => { + if (entry.isIntersecting) { + const idx = Number((entry.target as HTMLElement).dataset.rowindex); + if (!isNaN(idx)) visible.push(idx); + } + }); + if (visible.length > 0) { + onVisible(Math.max(...visible) + 1); + } + }, + { threshold: 0.1 } + ); + rowRefs.current.forEach((ref) => { + if (ref) observer.observe(ref); + }); + return () => { + observer.disconnect(); + }; + }, [rowCount, onVisible]); + return rowRefs; +} +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Input } from "@/components/ui/input"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [maxPageSize, setMaxPageSize] = React.useState(25); + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + state: { + pagination: { + pageIndex: 0, + pageSize: maxPageSize, //custom default page size + }, + }, + }); + + // Intersection Observer logic + const rows = table.getRowModel().rows; + const rowRefs = useVisibleRows(rows.length, (maxIdx) => { + setMaxPageSize((prev) => { + return Math.max(maxIdx + 25, prev); + }); + }); + + return ( +
+
+ + table.getColumn("dealTitle")?.setFilterValue(event.target.value) + } + className="w-full" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row: any, idx: number) => { + return ( + { + rowRefs.current[idx] = el; + }} + > + {row.getVisibleCells().map((cell: any) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ); + }) + ) : ( + + + No results. + + + )} + +
+
+
+ ); +} diff --git a/app/(protected)/notifications/page.tsx b/app/(protected)/notifications/page.tsx index 81962fe..e1f88d1 100644 --- a/app/(protected)/notifications/page.tsx +++ b/app/(protected)/notifications/page.tsx @@ -10,6 +10,8 @@ import { Notification } from "@prisma/client"; import { Circle, Dot, TrendingUp } from "lucide-react"; import { useSession } from "next-auth/react"; import { useState, useTransition, useRef, useCallback, useEffect } from "react"; +import { DataTable } from "./data-table"; +import { columns } from "./columns"; type PendingDeal = { id: string; @@ -34,6 +36,7 @@ export default function Notifications() { (notif) => notif.status === "COMPLETED", ); + if (!wsConnected) return
Loading...
; @@ -79,7 +82,8 @@ export default function Notifications() {

Completed - {completedDeals.length}

-
+ + {/* ))} -
+
*/} ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2c8f72b..0281e5a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -235,7 +235,5 @@ model Notification { dealId String dealTitle String status NotificationStatus - seen Boolean @default(false) - // eventually use this for TTL createdAt DateTime @default(now()) } \ No newline at end of file