diff --git a/src/components/NavigationMenu/NotificationItem.tsx b/src/components/NavigationMenu/NotificationItem.tsx index 0958cdd4..0a2c1775 100644 --- a/src/components/NavigationMenu/NotificationItem.tsx +++ b/src/components/NavigationMenu/NotificationItem.tsx @@ -1,7 +1,9 @@ import { cn } from '@/utils/cn'; import { T } from '@/components/ui/Typography'; +import { UserNotification } from '@/utils/zod-schemas/notifications'; import { useMutation } from '@tanstack/react-query'; +import * as LucideIcons from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { readNotification } from './fetchClientNotifications'; @@ -10,13 +12,13 @@ type NotificationItemProps = { title: string; description: string; href?: string; - onClick?: () => void; image: string; isRead: boolean; - createdAt: string; isNew: boolean; + createdAt: string; notificationId: string; onHover: () => void; + type: UserNotification['type'] | 'unknown'; }; export function NotificationItem({ @@ -26,10 +28,10 @@ export function NotificationItem({ image, isRead, isNew, - onClick, createdAt, notificationId, onHover, + type, }: NotificationItemProps) { const router = useRouter(); const { mutate: mutateReadMutation } = useMutation( @@ -42,57 +44,75 @@ export function NotificationItem({ }, }, ); - const content = ( + + const IconComponent = (LucideIcons[image as keyof typeof LucideIcons] as React.ComponentType) || LucideIcons.Layers; + + const getBackgroundColor = (type: UserNotification['type'] | 'unknown') => { + switch (type) { + case 'applyFailure': + case 'policyViolation': + return 'bg-red-100/50 dark:bg-red-900/20'; + case 'planNeedsApproval': + return 'bg-purple-100/50 dark:bg-purple-900/20'; + case 'projectDrifted': + return 'bg-yellow-100/50 dark:bg-yellow-900/20'; + case 'planApproved': + return 'bg-green-100 dark:bg-green-900/20'; + case 'planRejected': + return 'bg-orange-100/50 dark:bg-orange-900/20'; + default: + return 'bg-muted'; + } + }; + + const getIconColor = (type: UserNotification['type'] | 'unknown') => { + switch (type) { + case 'applyFailure': + case 'policyViolation': + return 'text-red-600 dark:text-red-400'; + case 'planNeedsApproval': + return 'text-purple-700 dark:text-purple-400'; + case 'projectDrifted': + return 'text-yellow-700 dark:text-yellow-400'; + case 'planApproved': + return 'text-green-700 dark:text-green-400'; + case 'planRejected': + return 'text-orange-700 dark:text-orange-400'; + default: + return 'text-blue-700 dark:text-blue-400'; + } + }; + + return (
-
-
- {title} -
- - {title} - - - {description} - - - {createdAt} - -
-
- - {isNew && ( -
+
+ +
+
+ + {title} + + + {description} + + {href && ( + mutateReadMutation()}> + See details + )} + + {createdAt} +
+ {isNew && ( +
+ )}
); - if (href) { - return ( - mutateReadMutation()} - href={href} - className="w-full flex flex-col items-center" - > - {content} - - ); - } else { - return ( -
- {content} -
- ); - } -} +} \ No newline at end of file diff --git a/src/components/NavigationMenu/Notifications.tsx b/src/components/NavigationMenu/Notifications.tsx index 973ce7e1..d83aaa73 100644 --- a/src/components/NavigationMenu/Notifications.tsx +++ b/src/components/NavigationMenu/Notifications.tsx @@ -11,13 +11,14 @@ import { useSAToastMutation } from '@/hooks/useSAToastMutation'; import { supabaseUserClientComponentClient } from '@/supabase-clients/user/supabaseUserClientComponentClient'; import type { Table } from '@/types'; import { parseNotification } from '@/utils/parseNotification'; +import { UserNotification } from '@/utils/zod-schemas/notifications'; import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'; import { Bell, Check } from 'lucide-react'; import moment from 'moment'; import { useRouter } from 'next/navigation'; -import { useCallback, useEffect } from 'react'; +import { useEffect } from 'react'; import { useDidMount } from 'rooks'; -import { toast } from 'sonner'; +import { ScrollArea } from '../ui/scroll-area'; import { Skeleton } from '../ui/skeleton'; import { getPaginatedNotifications, @@ -26,6 +27,105 @@ import { seeNotification } from './fetchClientNotifications'; +const testNotifications: (UserNotification & Pick, 'id' | 'created_at' | 'is_read' | 'is_seen'>)[] = [ + { + id: 'test-apply-failure', + type: 'applyFailure', + projectName: 'Test Project', + projectId: 'test-project-id', + commitId: 'a34fe', + userId: 'motatoes', + reason: 'terraform init failed', + dashboardUrl: 'https://nextjs-dashboard.com/run/123', + created_at: '2024-07-17T13:39:26.231Z', + is_read: false, + is_seen: false, + }, + { + id: 'test-plan-needs-approval', + type: 'planNeedsApproval', + projectName: 'Test Project', + projectId: 'test-project-id', + commitId: 'a3456', + dashboardUrl: 'https://nextjs-dashboard.com/run/456', + created_at: '2024-07-17T12:39:26.231Z', + is_read: false, + is_seen: false, + }, + { + id: 'test-project-drifted', + type: 'projectDrifted', + projectName: 'Test Project', + projectId: 'test-project-id', + dashboardUrl: 'https://nextjs-dashboard.com/drift/789', + created_at: '2024-07-16T14:39:26.231Z', + is_read: false, + is_seen: false, + }, + { + id: 'test-policy-violation', + type: 'policyViolation', + projectName: 'Test Project', + projectId: 'test-project-id', + dashboardUrl: 'https://nextjs-dashboard.com/policy-violation/101', + created_at: '2024-07-16T06:58:22.621Z', + is_read: false, + is_seen: false, + }, + { + id: 'test-plan-approved', + type: 'planApproved', + planName: 'Test Plan', + planId: 'test-plan-id', + projectName: 'Test Project', + projectId: 'test-project-id', + approverName: 'John Doe', + approverId: 'test-approver-id', + created_at: '2024-07-16T06:58:22.621Z', + is_read: false, + is_seen: false, + }, + { + id: 'test-plan-rejected', + type: 'planRejected', + planName: 'Test Plan', + planId: 'test-plan-id', + projectName: 'Test Project', + projectId: 'test-project-id', + rejectorName: 'Jane Doe', + rejectorId: 'test-rejector-id', + created_at: '2024-07-16T06:58:22.621Z', + is_read: false, + is_seen: false, + }, +]; + + +export const TestNotifications: React.FC = () => { + return ( + <> + {testNotifications.map((notification) => { + const parsedNotification = parseNotification(notification); + return ( + {/* Handle hover */ }} + type={parsedNotification.type} + /> + ); + })} + + ); +}; + const NOTIFICATIONS_PAGE_SIZE = 10; const useUnseenNotificationIds = (userId: string) => { const { data, refetch } = useQuery( @@ -129,12 +229,6 @@ function Notification({ }) { const router = useRouter(); const notificationPayload = parseNotification(notification.payload); - const handleNotificationClick = useCallback(() => { - if (notificationPayload.type === 'welcome') { - toast('Welcome to Nextbase'); - } - }, [notificationPayload]); - const { mutate: mutateSeeMutation } = useMutation( async () => { return await seeNotification(notification.id); @@ -157,11 +251,6 @@ function Notification({ ? notificationPayload.href : undefined } - onClick={ - notificationPayload.actionType === 'button' - ? handleNotificationClick - : undefined - } image={notificationPayload.image} isRead={notification.is_read} isNew={!notification.is_seen} @@ -171,6 +260,7 @@ function Notification({ mutateSeeMutation(); } }} + type={notificationPayload.type} /> ); } @@ -202,7 +292,7 @@ export const useReadAllNotifications = (userId: string) => { ); }; -export const Notifications = ({ userId, }: { userId: string }) => { +export const Notifications = ({ userId }: { userId: string }) => { const unseenNotificationIds = useUnseenNotificationIds(userId); const { notifications, @@ -252,16 +342,16 @@ export const Notifications = ({ userId, }: { userId: string }) => { {notifications.length > 0 || unseenNotificationIds?.length > 0 ? ( - -
-
- + +
+
+ Notifications -
+
{unseenNotificationIds?.length > 0 ? ( <> - {' '} + { mutate(); @@ -271,48 +361,53 @@ export const Notifications = ({ userId, }: { userId: string }) => { mutate(); } }} - className="dark:group-hover:text-gray-400 text-muted-foreground underline underline-offset-4" + className="dark:group-hover:text-gray-400 text-muted-foreground underline underline-offset-2" > - Mark as all read + Mark all as read ) : null}
-
- {isLoading ? ( - - ) : ( - notifications?.map((notification) => { - return ( - - ); - }) - )} - {hasNextPage ? ( - isFetchingNextPage ? ( - + +
+ {/* Add this line to include the test notifications */} + + + {isLoading ? ( + ) : ( - - ) - ) : ( - - No more notifications - - )} -
+ <> + {notifications?.map((notification) => ( + + ))} + + )} + {hasNextPage ? ( + isFetchingNextPage ? ( + + ) : ( + + ) + ) : ( + + No more notifications + + )} +
+ ) : ( -
- No notifications yet. +
+ No notifications yet.
)} ); -}; +}; \ No newline at end of file diff --git a/src/utils/parseNotification.ts b/src/utils/parseNotification.ts index 28b1e736..2b0563ef 100644 --- a/src/utils/parseNotification.ts +++ b/src/utils/parseNotification.ts @@ -1,157 +1,211 @@ -import { PRODUCT_NAME } from "@/constants"; +import { PRODUCT_NAME } from '@/constants'; import { userNotificationPayloadSchema, type UserNotification, -} from "./zod-schemas/notifications"; +} from './zod-schemas/notifications'; type NormalizedNotification = { - title: string; - description: string; - image: string; - type: UserNotification["type"] | "unknown"; + title: string; + description: string; + image: string; + type: UserNotification['type'] | 'unknown'; + detailsLink?: string; } & ( - | { - actionType: "link"; - href: string; - } - | { - actionType: "button"; - } + | { + actionType: 'link'; + href: string; + } + | { + actionType: 'button'; + } ); export const parseNotification = ( - notificationPayload: unknown, + notificationPayload: unknown, ): NormalizedNotification => { - try { - const notification = - userNotificationPayloadSchema.parse(notificationPayload); - switch (notification.type) { - case "invitedToOrganization": - return { - title: "Invitation to join organization", - description: `You have been invited to join ${notification.organizationName}`, - // 2 days ago - href: `/invitations/${notification.invitationId}`, - image: "/logos/logo-black.png", - actionType: "link", - type: notification.type, - }; - case "acceptedOrganizationInvitation": - return { - title: "Accepted invitation to join organization", - description: `${notification.userFullName} has accepted your invitation to join your organization`, - href: `/organization/${notification.organizationId}/settings/members`, - image: "/logos/logo-black.png", - actionType: "link", - type: notification.type, - }; - case "welcome": - return { - title: "Welcome to the Nextbase", - description: - "Welcome to the Nextbase Ultimate. We are glad to see you here!", - actionType: "button", - image: "/logos/logo-black.png", - type: notification.type, - }; - case "receivedFeedback": - return { - title: `${PRODUCT_NAME} received new feedback`, - description: `${notification.feedbackCreatorFullName} said: ${notification.feedbackTitle}`, - image: "/logos/logo-black.png", - actionType: "link", - href: `/feedback/${notification.feedbackId}`, - type: notification.type, - }; - case "feedbackReceivedComment": - return { - title: `New comment on ${notification.feedbackTitle}`, - description: `${notification.commenterName} says: ${ - notification.comment.slice(0, 50) + "..." - }`, - image: "/logos/logo-black.png", - actionType: "link", - href: `/feedback/${notification.feedbackId}`, - type: notification.type, - }; - case "feedbackStatusChanged": - return { - title: `Your feedback was updated.`, - description: `Your feedback status was updated from ${notification.oldStatus} to ${notification.newStatus}`, - image: "/logos/logo-black.png", - actionType: "link", - href: `/feedback/${notification.feedbackId}`, - type: notification.type, - }; - case "feedbackPriorityChanged": - return { - title: `Your feedback was updated.`, - description: `Your feedback priority was updated from ${notification.oldPriority} to ${notification.newPriority}`, - image: "/logos/logo-black.png", - actionType: "link", - href: `/feedback/${notification.feedbackId}`, - type: notification.type, - }; - case "feedbackTypeUpdated": - return { - title: `Your feedback was updated.`, - description: `Your feedback priority was updated from ${notification.oldType} to ${notification.newType}`, - image: "/logos/logo-black.png", - actionType: "link", - href: `/feedback/${notification.feedbackId}`, - type: notification.type, - }; - case "feedbackIsInRoadmapUpdated": - return { - title: `Your feedback was updated.`, - description: `Your feedback is now ${ - notification.isInRoadmap ? "added to" : "removed from" - } roadmap.`, - image: "/logos/logo-black.png", - actionType: "link", - href: `/feedback/${notification.feedbackId}`, - type: notification.type, - }; - case "feedbackVisibilityUpdated": - return { - title: `Your feedback was updated.`, - description: `Your feedback is now ${ - notification.isPubliclyVisible ? "visible to" : "hidden from" - } public.`, - image: "/logos/logo-black.png", - actionType: "link", - href: `/feedback/${notification.feedbackId}`, - type: notification.type, - }; - case "feedbackFeedbackOpenForCommentUpdated": - return { - title: `Your feedback was updated.`, - description: `Your feedback is now ${ - notification.isOpenForComments ? "open" : "closed to" - } comments.`, - image: "/logos/logo-black.png", - actionType: "link", - href: `/feedback/${notification.feedbackId}`, - type: notification.type, - }; - default: { - return { - title: "Unknown notification type", - description: "Unknown notification type", - href: "#", - image: "/logos/logo-black.png", - actionType: "link", - type: "unknown", - }; - } - } - } catch (error) { - return { - title: "Unknown notification type", - description: "Unknown notification type", - image: "/logos/logo-black.png", - actionType: "button", - type: "unknown", - }; - } + try { + const notification = + userNotificationPayloadSchema.parse(notificationPayload); + switch (notification.type) { + case 'applyFailure': + return { + title: 'Apply Failure', + description: `Apply by user ${notification.userId} has failed in commit ${notification.commitId}. Reason: ${notification.reason}.`, + image: 'AlertTriangle', + actionType: 'link', + href: notification.dashboardUrl, + type: notification.type, + }; + case 'planNeedsApproval': + return { + title: 'Plan Needs Approval', + description: `Digger apply is pending user approval for commit ${notification.commitId}.`, + image: 'ClipboardCheck', + actionType: 'link', + href: notification.dashboardUrl, + type: notification.type, + }; + case 'projectDrifted': + return { + title: 'Project Drifted', + description: `Project ${notification.projectName} has drift detected.`, + image: 'GitCompare', + actionType: 'link', + href: notification.dashboardUrl, + type: notification.type, + }; + case 'policyViolation': + return { + title: 'Policy Violation Detected', + description: `A policy violation was detected in ${notification.projectName}.`, + image: 'ShieldAlert', + actionType: 'link', + href: notification.dashboardUrl, + type: notification.type, + }; + case 'invitedToOrganization': + return { + title: 'Invitation to join organization', + description: `You have been invited to join ${notification.organizationName}`, + href: `/invitations/${notification.invitationId}`, + image: 'Users', + type: notification.type, + actionType: 'link', + }; + case 'acceptedOrganizationInvitation': + return { + title: 'Accepted invitation to join organization', + description: `${notification.userFullName} has accepted your invitation to join your organization`, + href: `/organization/${notification.organizationId}/settings/members`, + image: 'UserCheck', + type: notification.type, + actionType: 'link', + }; + case 'welcome': + return { + title: 'Welcome to the Nextbase', + description: + 'Welcome to the Nextbase Ultimate. We are glad to see you here!', + actionType: 'button', + image: 'Hand', + type: notification.type, + }; + case 'receivedFeedback': + return { + title: `${PRODUCT_NAME} received new feedback`, + description: `${notification.feedbackCreatorFullName} said: ${notification.feedbackTitle}`, + image: 'MessageSquare', + actionType: 'link', + href: `/feedback/${notification.feedbackId}`, + type: notification.type, + }; + case 'feedbackReceivedComment': + return { + title: `New comment on ${notification.feedbackTitle}`, + description: `${notification.commenterName} says: ${ + notification.comment.slice(0, 50) + '...' + }`, + image: 'MessageCircle', + actionType: 'link', + href: `/feedback/${notification.feedbackId}`, + type: notification.type, + }; + case 'feedbackStatusChanged': + return { + title: `Your feedback was updated.`, + description: `Your feedback status was updated from ${notification.oldStatus} to ${notification.newStatus}`, + image: 'RefreshCw', + actionType: 'link', + href: `/feedback/${notification.feedbackId}`, + type: notification.type, + }; + case 'feedbackPriorityChanged': + return { + title: `Your feedback was updated.`, + description: `Your feedback priority was updated from ${notification.oldPriority} to ${notification.newPriority}`, + image: 'ArrowUpCircle', + actionType: 'link', + href: `/feedback/${notification.feedbackId}`, + type: notification.type, + }; + case 'feedbackTypeUpdated': + return { + title: `Your feedback was updated.`, + description: `Your feedback type was updated from ${notification.oldType} to ${notification.newType}`, + image: 'Tag', + actionType: 'link', + href: `/feedback/${notification.feedbackId}`, + type: notification.type, + }; + case 'feedbackIsInRoadmapUpdated': + return { + title: `Your feedback was updated.`, + description: `Your feedback is now ${ + notification.isInRoadmap ? 'added to' : 'removed from' + } roadmap.`, + image: 'Map', + actionType: 'link', + href: `/feedback/${notification.feedbackId}`, + type: notification.type, + }; + case 'feedbackVisibilityUpdated': + return { + title: `Your feedback was updated.`, + description: `Your feedback is now ${ + notification.isPubliclyVisible ? 'visible to' : 'hidden from' + } public.`, + image: 'Eye', + actionType: 'link', + href: `/feedback/${notification.feedbackId}`, + type: notification.type, + }; + case 'feedbackFeedbackOpenForCommentUpdated': + return { + title: `Your feedback was updated.`, + description: `Your feedback is now ${ + notification.isOpenForComments ? 'open' : 'closed to' + } comments.`, + image: 'MessageSquare', + actionType: 'link', + href: `/feedback/${notification.feedbackId}`, + type: notification.type, + }; + case 'planApproved': + return { + title: 'Plan Approved', + description: `${notification.approverName} approved the plan for ${notification.projectName}`, + image: 'CheckCircle', + actionType: 'link', + href: `/projects/${notification.projectId}/plans/${notification.planId}`, + type: notification.type, + }; + case 'planRejected': + return { + title: 'Plan Rejected', + description: `${notification.rejectorName} rejected the plan for ${notification.projectName}`, + image: 'XCircle', + actionType: 'link', + href: `/projects/${notification.projectId}/plans/${notification.planId}`, + type: notification.type, + }; + default: { + return { + title: 'Unknown notification type', + description: 'Unknown notification type', + href: '#', + image: 'Layers', + actionType: 'link', + type: 'unknown', + }; + } + } + } catch (error) { + return { + title: 'Unknown notification type', + description: 'Unknown notification type', + image: 'help-circle', + actionType: 'button', + type: 'unknown', + }; + } }; diff --git a/src/utils/zod-schemas/notifications.ts b/src/utils/zod-schemas/notifications.ts index 44c2048c..b81806a5 100644 --- a/src/utils/zod-schemas/notifications.ts +++ b/src/utils/zod-schemas/notifications.ts @@ -30,7 +30,7 @@ const feedbackReceivedCommentPayload = z.object({ feedbackTitle: z.string(), feedbackId: z.string(), commenterName: z.string(), - comment: z.string() + comment: z.string(), }); const feedbackStatusChangedPayload = z.object({ @@ -47,7 +47,6 @@ const feedbackPriorityChangedPayload = z.object({ newPriority: z.string(), }); - const feedbackTypeUpdatedPayload = z.object({ type: z.literal('feedbackTypeUpdated'), feedbackId: z.string(), @@ -58,19 +57,71 @@ const feedbackTypeUpdatedPayload = z.object({ const feedbackIsInRoadmapUpdatedPayload = z.object({ type: z.literal('feedbackIsInRoadmapUpdated'), feedbackId: z.string(), - isInRoadmap: z.boolean() + isInRoadmap: z.boolean(), }); const feedbackVisibilityUpdatedPayload = z.object({ type: z.literal('feedbackVisibilityUpdated'), feedbackId: z.string(), - isPubliclyVisible: z.boolean() + isPubliclyVisible: z.boolean(), }); const feedbackFeedbackOpenForCommentUpdatedPayload = z.object({ type: z.literal('feedbackFeedbackOpenForCommentUpdated'), feedbackId: z.string(), - isOpenForComments: z.boolean() + isOpenForComments: z.boolean(), +}); + +const applyFailurePayload = z.object({ + type: z.literal('applyFailure'), + projectName: z.string(), + projectId: z.string(), + commitId: z.string(), + userId: z.string(), + reason: z.string(), + dashboardUrl: z.string(), +}); + +const planNeedsApprovalPayload = z.object({ + type: z.literal('planNeedsApproval'), + projectName: z.string(), + projectId: z.string(), + commitId: z.string(), + dashboardUrl: z.string(), +}); + +const planApprovedPayload = z.object({ + type: z.literal('planApproved'), + planName: z.string(), + planId: z.string(), + projectId: z.string(), + projectName: z.string(), + approverName: z.string(), + approverId: z.string(), +}); + +const planRejectedPayload = z.object({ + type: z.literal('planRejected'), + planName: z.string(), + planId: z.string(), + projectName: z.string(), + projectId: z.string(), + rejectorName: z.string(), + rejectorId: z.string(), +}); + +const projectDriftedPayload = z.object({ + type: z.literal('projectDrifted'), + projectName: z.string(), + projectId: z.string(), + dashboardUrl: z.string(), +}); + +const policyViolationPayload = z.object({ + type: z.literal('policyViolation'), + projectName: z.string(), + projectId: z.string(), + dashboardUrl: z.string(), }); export const userNotificationPayloadSchema = z.union([ @@ -84,7 +135,13 @@ export const userNotificationPayloadSchema = z.union([ feedbackTypeUpdatedPayload, feedbackIsInRoadmapUpdatedPayload, feedbackVisibilityUpdatedPayload, - feedbackFeedbackOpenForCommentUpdatedPayload + feedbackFeedbackOpenForCommentUpdatedPayload, + applyFailurePayload, + planNeedsApprovalPayload, + planApprovedPayload, + planRejectedPayload, + projectDriftedPayload, + policyViolationPayload, ]); export type UserNotification = z.infer;