Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions app/me/notifications/components/NotificationDetailSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
'use client';

import { Notification } from '@/types/notifications';
import BoundlessSheet from '@/components/sheet/boundless-sheet';
import { getNotificationIcon } from '@/components/notifications/NotificationIcon';
import { format, formatDistanceToNow } from 'date-fns';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';

interface NotificationDetailSheetProps {
notification: Notification | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}

const metadataLabels: Record<string, string> = {
organizationName: 'Organization',
hackathonName: 'Hackathon',
projectName: 'Project',
memberEmail: 'Member',
role: 'Role',
oldRole: 'Previous Role',
newRole: 'New Role',
oldStatus: 'Previous Status',
newStatus: 'New Status',
submissionStatus: 'Submission Status',
deadlineType: 'Deadline',
amount: 'Amount',
transactionHash: 'Transaction',
};

export const NotificationDetailSheet = ({
notification,
open,
onOpenChange,
}: NotificationDetailSheetProps) => {
if (!notification) return null;

const Icon = getNotificationIcon(notification.type);
const createdAt = new Date(notification.createdAt);

const metadata = Object.entries(notification.data).filter(
([key, value]) =>
value !== undefined &&
value !== null &&
key in metadataLabels &&
key !== 'organizationId' &&
key !== 'hackathonId' &&
key !== 'projectId' &&
key !== 'commentId' &&
key !== 'milestoneId' &&
key !== 'teamInvitationId' &&
key !== 'hackathonSlug'
);

return (
<BoundlessSheet
open={open}
setOpen={onOpenChange}
title='Notification Details'
size='default'
>
<div className='px-6 pb-8 md:px-12'>
{/* Notification header */}
<div className='flex items-start gap-4 pt-2'>
<div
className={cn(
'flex h-12 w-12 shrink-0 items-center justify-center rounded-full border',
!notification.read
? 'border-primary/20 bg-primary/10 text-primary'
: 'border-zinc-800 bg-zinc-900 text-zinc-400'
)}
>
<Icon className='h-6 w-6' />
</div>
<div className='min-w-0 flex-1'>
<h3 className='text-lg font-semibold text-white'>
{notification.title}
</h3>
<div className='mt-1 flex items-center gap-2 text-xs text-zinc-500'>
<time dateTime={notification.createdAt}>
{format(createdAt, 'MMM d, yyyy · h:mm a')}
</time>
<span className='text-zinc-700'>·</span>
<span>{formatDistanceToNow(createdAt, { addSuffix: true })}</span>
</div>
</div>
<Badge
variant={notification.read ? 'outline' : 'default'}
className={cn(
'shrink-0 text-[10px]',
notification.read
? 'border-zinc-800 text-zinc-500'
: 'bg-primary/20 text-primary border-primary/30'
)}
>
{notification.read ? 'Read' : 'Unread'}
</Badge>
</div>

{/* Divider */}
<div className='my-6 border-t border-zinc-800/50' />

{/* Message body */}
<div className='rounded-lg border border-zinc-800/30 bg-zinc-900/30 p-5'>
<p className='text-sm leading-relaxed text-zinc-300'>
{notification.message}
</p>
</div>

{/* Metadata */}
{metadata.length > 0 && (
<div className='mt-6'>
<h4 className='mb-3 text-xs font-semibold tracking-widest text-zinc-500 uppercase'>
Details
</h4>
<div className='space-y-2'>
{metadata.map(([key, value]) => (
<div
key={key}
className='flex items-center justify-between rounded-lg border border-zinc-800/20 bg-zinc-900/20 px-4 py-2.5'
>
<span className='text-xs text-zinc-500'>
{metadataLabels[key] || key}
</span>
<span className='text-sm font-medium text-zinc-300'>
{key === 'amount'
? `$${(value as number).toLocaleString()}`
: key === 'transactionHash'
? `${String(value).slice(0, 8)}...${String(value).slice(-6)}`
: String(value)}
</span>
</div>
))}
</div>
</div>
)}

{/* Type tag */}
<div className='mt-6'>
<span className='inline-flex rounded-full border border-zinc-800/50 bg-zinc-900/50 px-3 py-1 text-[10px] font-medium tracking-wider text-zinc-500'>
{notification.type.replace(/_/g, ' ')}
</span>
</div>
</div>
</BoundlessSheet>
);
};
95 changes: 95 additions & 0 deletions app/me/notifications/components/NotificationFeedItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use client';

import { formatDistanceToNow } from 'date-fns';
import { Notification } from '@/types/notifications';
import { getNotificationIcon } from '@/components/notifications/NotificationIcon';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'motion/react';

interface NotificationFeedItemProps {
notification: Notification;
onClick: () => void;
isMarkingAll: boolean;
}

export const NotificationFeedItem = ({
notification,
onClick,
isMarkingAll,
}: NotificationFeedItemProps) => {
const Icon = getNotificationIcon(notification.type);
const isUnread = !notification.read;

return (
<button
onClick={onClick}
className={cn(
'group relative flex w-full items-start gap-4 p-4 text-left transition-all duration-200',
isUnread ? 'bg-primary/3 hover:bg-primary/6' : 'hover:bg-zinc-900/40'
)}
aria-label={`${isUnread ? 'Unread: ' : ''}${notification.title}`}
>
{/* Icon */}
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full border transition-colors',
isUnread
? 'border-primary/20 bg-primary/10 text-primary'
: 'border-zinc-800 bg-zinc-900 text-zinc-500 group-hover:text-zinc-400'
)}
>
<Icon className='h-5 w-5' />
</div>

{/* Content */}
<div className='min-w-0 flex-1'>
<div className='flex items-start justify-between gap-2'>
<h4
className={cn(
'text-sm leading-snug',
isUnread
? 'font-semibold text-zinc-100'
: 'font-normal text-zinc-400 group-hover:text-zinc-300'
)}
>
{notification.title}
</h4>
<span className='shrink-0 text-[10px] whitespace-nowrap text-zinc-600'>
{formatDistanceToNow(new Date(notification.createdAt), {
addSuffix: true,
})}
</span>
</div>

<p
className={cn(
'mt-0.5 line-clamp-2 text-xs leading-relaxed',
isUnread ? 'text-zinc-400' : 'text-zinc-600'
)}
>
{notification.message}
</p>

{notification.data.amount && (
<div className='mt-1.5 inline-flex items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-400'>
<span>$</span>
<span>{notification.data.amount.toLocaleString()}</span>
</div>
)}
</div>

{/* Unread indicator */}
<AnimatePresence>
{isUnread && !isMarkingAll && (
<motion.div
initial={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
className='bg-primary absolute top-1/2 right-3 h-2 w-2 -translate-y-1/2 rounded-full shadow-[0_0_8px_rgba(var(--primary),0.4)]'
aria-hidden='true'
/>
)}
</AnimatePresence>
</button>
);
};
79 changes: 79 additions & 0 deletions app/me/notifications/components/NotificationSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client';

import { Notification } from '@/types/notifications';
import { NotificationFeedItem } from './NotificationFeedItem';
import { motion, AnimatePresence } from 'motion/react';
import { Archive, Bell, Inbox } from 'lucide-react';

interface NotificationSectionProps {
title: string;
notifications: Notification[];
onNotificationClick: (notification: Notification) => void;
isMarkingAll: boolean;
emptyMessage: string;
emptySubMessage: string;
}

const sectionIcons: Record<string, typeof Bell> = {
New: Bell,
Earlier: Inbox,
Archived: Archive,
};

export const NotificationSection = ({
title,
notifications,
onNotificationClick,
isMarkingAll,
emptyMessage,
emptySubMessage,
}: NotificationSectionProps) => {
const Icon = sectionIcons[title] ?? Bell;

return (
<section aria-label={`${title} notifications`}>
<div className='mb-3 flex items-center gap-2'>
<Icon className='h-4 w-4 text-zinc-500' />
<h2 className='text-xs font-semibold tracking-widest text-zinc-500 uppercase'>
{title}
</h2>
{notifications.length > 0 && (
<span className='rounded-full bg-zinc-800/60 px-2 py-0.5 text-[10px] font-medium text-zinc-400'>
{notifications.length}
</span>
)}
</div>

<div className='rounded-xl border border-zinc-800/30'>
{notifications.length === 0 ? (
<div className='py-8 text-center'>
<p className='text-sm text-zinc-400'>{emptyMessage}</p>
<p className='mt-1 text-xs text-zinc-600'>{emptySubMessage}</p>
</div>
) : (
<AnimatePresence mode='sync'>
<div className='divide-y divide-zinc-800/30'>
{notifications.map((notification, index) => (
<motion.div
key={notification.id}
initial={{ opacity: 1 }}
animate={{
opacity: isMarkingAll && !notification.read ? 0.5 : 1,
}}
transition={{ duration: 0.4, delay: index * 0.03 }}
layout
>
<NotificationFeedItem
notification={notification}
onClick={() => onNotificationClick(notification)}
isMarkingAll={isMarkingAll}
/>
</motion.div>
))}
</div>
</AnimatePresence>
)}
</div>
</section>
);
};
Loading
Loading