From a090bf43d27b6a56a84577cbbef7ecf1afe04be2 Mon Sep 17 00:00:00 2001 From: Terbau Date: Wed, 4 Mar 2026 20:29:36 +0100 Subject: [PATCH] feat: add notification dropdown --- .../Notifications/NotificationDropdown.tsx | 104 +++++++++++++++ .../Navbar/Notifications/NotificationItem.tsx | 120 ++++++++++++++++++ .../web/src/components/Navbar/ProfileMenu.tsx | 6 +- packages/types/src/index.ts | 1 + packages/types/src/notifications.ts | 14 ++ 5 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/Navbar/Notifications/NotificationDropdown.tsx create mode 100644 apps/web/src/components/Navbar/Notifications/NotificationItem.tsx create mode 100644 packages/types/src/notifications.ts diff --git a/apps/web/src/components/Navbar/Notifications/NotificationDropdown.tsx b/apps/web/src/components/Navbar/Notifications/NotificationDropdown.tsx new file mode 100644 index 0000000000..1fb694e9e2 --- /dev/null +++ b/apps/web/src/components/Navbar/Notifications/NotificationDropdown.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState, type ComponentProps } from "react" +import * as DropdownMenu from "@radix-ui/react-dropdown-menu" +import { NotificationItem, type NotificationItemType } from "./NotificationItem" +import { IconBell, IconLoader2 } from "@tabler/icons-react" +import { cn } from "@dotkomonline/ui" + +interface NotificationDropdownProps extends ComponentProps { + items?: NotificationItemType[] + hasMoreItems?: boolean + isLoading?: boolean + onReadMore?: () => void +} + +export const NotificationDropdown = ({ + items = [], + hasMoreItems = false, + isLoading, + onReadMore, + open, + ...props +}: NotificationDropdownProps) => { + const handleMarkAllAsRead = () => { + console.log("Mark all as read clicked") + } + + const handleItemClick = (item: NotificationItemType) => { + // Use to set readAt. The redirection to the item should not happen in this callback. + console.log("Notification clicked:", item) + } + + const [infiniteScrollEl, setInfiniteScrollEl] = useState(null) + + // If the bottom div is visible within the scroll container, we attempt to fetch more items. + // Using a state-based callback ref so the effect re-fires when the portal mounts the element. + useEffect(() => { + if (infiniteScrollEl === null) { + return + } + const observer = new IntersectionObserver((entries) => { + if (entries.some((e) => e.isIntersecting) && hasMoreItems) { + onReadMore?.() + } + }) + observer.observe(infiniteScrollEl) + return () => { + observer.disconnect() + } + }, [infiniteScrollEl, hasMoreItems, onReadMore]) + + return ( + + + + + + +
+
+

Varslinger

+ +
+

SISTE OPPDATERINGER

+
+ +
+ {items.length ? ( + <> + {items.map((item) => ( + handleItemClick(item)} /> + ))} + {hasMoreItems &&
} + + ) : ( + !isLoading &&

Ingen varslinger

+ )} + {isLoading && } +
+ + + + ) +} diff --git a/apps/web/src/components/Navbar/Notifications/NotificationItem.tsx b/apps/web/src/components/Navbar/Notifications/NotificationItem.tsx new file mode 100644 index 0000000000..c5a5e1cdbc --- /dev/null +++ b/apps/web/src/components/Navbar/Notifications/NotificationItem.tsx @@ -0,0 +1,120 @@ +import { + IconBookFilled, + IconBriefcaseFilled, + IconCalendarExclamation, + IconCalendarPlus, + IconCalendarTime, + IconCalendarUp, + IconMessageCircleFilled, + type IconProps, + IconSpeakerphone, + IconUsers, + IconVocabulary, +} from "@tabler/icons-react" +import type { ComponentProps, ForwardRefExoticComponent, RefAttributes } from "react" +import * as DropdownMenu from "@radix-ui/react-dropdown-menu" +import { Badge, cn } from "@dotkomonline/ui" +import type { NotificationPayloadType, NotificationType } from "@dotkomonline/types" + +export interface NotificationItemType { + id: string + createdAt: Date + updatedAt: Date + title: string + shortDescription: string + content: string + type: NotificationType + payload?: string + payloadType: NotificationPayloadType + actorGroupId: string + createdById?: string + lastUpdatedById?: string + taskId?: string + readAt?: Date +} + +export const NotificationIconMap: Record< + NotificationType, + ForwardRefExoticComponent> +> = { + BROADCAST: IconMessageCircleFilled, + BROADCAST_IMPORTANT: IconSpeakerphone, + EVENT_REGISTRATION: IconCalendarPlus, + EVENT_REMINDER: IconCalendarTime, + EVENT_UPDATE: IconCalendarExclamation, + JOB_LISTING_REMINDER: IconBriefcaseFilled, + NEW_ARTICLE: IconBookFilled, + NEW_EVENT: IconCalendarUp, + NEW_INTEREST_GROUP: IconUsers, + NEW_JOB_LISTING: IconBriefcaseFilled, + NEW_OFFLINE: IconVocabulary, + NEW_MARK: IconSpeakerphone, + NEW_FEEDBACK_FORM: IconSpeakerphone, +} + +interface NotificationItem extends ComponentProps { + notification: NotificationItemType + onItemClick?: () => void +} + +export const NotificationItem = ({ notification, onItemClick, className, ...props }: NotificationItem) => { + const Icon = NotificationIconMap[notification.type] + const isRead = !!notification.readAt + const isImportant = notification.type === "BROADCAST_IMPORTANT" + + const unreadItemColor = isImportant + ? "border-l-red-500 bg-red-500/5 hover:bg-red-500/10 focus-visible:bg-red-500/10 dark:focus-visible:bg-red-500/15" + : "border-l-blue-500 bg-blue-500/5 hover:bg-blue-500/10 focus-visible:bg-blue-500/10 dark:focus-visible:bg-blue-500/15" + const unreadDotColor = isImportant ? "bg-red-500" : "bg-blue-500" + const unreadTextColor = isImportant ? "text-red-600" : "text-blue-600" + const unreadIconBgColor = isImportant ? "bg-red-500/20" : "bg-blue-500/20" + + return ( + +
+
+
+ +
+ {!isRead &&
} +
+
+

5 timer siden

+ + {isImportant && ( + + Viktig melding + + )} + +

{notification.title}

+

{notification.shortDescription}

+
+
+ + ) +} diff --git a/apps/web/src/components/Navbar/ProfileMenu.tsx b/apps/web/src/components/Navbar/ProfileMenu.tsx index 8b4fad4eb7..defbd6504a 100644 --- a/apps/web/src/components/Navbar/ProfileMenu.tsx +++ b/apps/web/src/components/Navbar/ProfileMenu.tsx @@ -44,6 +44,7 @@ import { useTheme } from "next-themes" import Link from "next/link" import { type FC, Fragment, useEffect, useState } from "react" import { ThemeToggle } from "./ThemeToggle" +import { NotificationDropdown } from "./Notifications/NotificationDropdown" const DEBUG_CONTACT_URL = "https://docs.google.com/forms/d/e/1FAIpQLScvjEqVsiRIYnVqCNqbH_-nmYk3Ux6la8a7KZzsY3sJDbW-iA/viewform" @@ -235,8 +236,9 @@ export const ProfileMenu: FC = () => { } return ( -
+
+
) @@ -278,7 +280,7 @@ export const AvatarDropdown: FC = ({ dbUser, dbUserIsLoadin