Skip to content
Open
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
53 changes: 53 additions & 0 deletions app/api/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import { sql } from '@/lib/db'
import { getUserIdByWallet } from '@/lib/reputation'

export const GET = withAuth(async (request: NextRequest, auth) => {
const userId = await getUserIdByWallet(auth.walletAddress)
if (userId === null) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}

try {
const notifications = await sql`
SELECT id, type, title, message, link, is_read, created_at
FROM notifications
WHERE user_id = ${userId}
ORDER BY created_at DESC
LIMIT 50
`
return NextResponse.json(notifications)
} catch (error) {
console.error('Failed to fetch notifications:', error)
return NextResponse.json({ error: 'Failed to fetch notifications' }, { status: 500 })
}
})

export const PATCH = withAuth(async (request: NextRequest, auth) => {
const userId = await getUserIdByWallet(auth.walletAddress)
if (userId === null) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}

try {
const { id } = await request.json()
if (id) {
await sql`
UPDATE notifications
SET is_read = TRUE
WHERE id = ${id} AND user_id = ${userId}
`
} else {
await sql`
UPDATE notifications
SET is_read = TRUE
WHERE user_id = ${userId} AND is_read = FALSE
`
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to update notifications:', error)
return NextResponse.json({ error: 'Failed to update notifications' }, { status: 500 })
}
})
7 changes: 6 additions & 1 deletion components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Menu, X, Wallet } from 'lucide-react'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import { ThemeToggle } from './ui/ThemeToggle'
import { NotificationBell } from './notifications/notification-bell'

export function Navbar() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
Expand Down Expand Up @@ -69,7 +70,8 @@ export function Navbar() {
<Button asChild>
<Link href="/signup">Get Started</Link>
</Button>
<ThemeToggle />
<ThemeToggle />
<NotificationBell />
</>
)}

Expand Down Expand Up @@ -116,6 +118,9 @@ export function Navbar() {
<Button className="w-full" asChild>
<Link href="/signup">Get Started</Link>
</Button>
<div className="flex justify-center pt-2">
<NotificationBell />
</div>
</>
)}
</div>
Expand Down
136 changes: 136 additions & 0 deletions components/notifications/notification-bell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"use client"

import { formatDistanceToNow } from 'date-fns'
import { Bell, Check, Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'

interface Notification {
id: number
type: string
title: string
message: string
link: string | null
is_read: boolean
created_at: string
}

export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [loading, setLoading] = useState(true)
const [unreadCount, setUnreadCount] = useState(0)

const fetchNotifications = async () => {
try {
const res = await fetch('/api/notifications')
if (res.ok) {
const data = await res.json()
setNotifications(data)
setUnreadCount(data.filter((n: Notification) => !n.is_read).length)
}
} catch (error) {
console.error('Failed to fetch notifications:', error)
} finally {
setLoading(false)
}
}

const markAllAsRead = async () => {
try {
const res = await fetch('/api/notifications', { method: 'PATCH', body: JSON.stringify({}) })
if (res.ok) {
setNotifications(notifications.map(n => ({ ...n, is_read: true })))
setUnreadCount(0)
}
} catch (error) {
console.error('Failed to mark all as read:', error)
}
}

const markAsRead = async (id: number) => {
try {
const res = await fetch('/api/notifications', { method: 'PATCH', body: JSON.stringify({ id }) })
if (res.ok) {
setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: true } : n))
setUnreadCount(prev => Math.max(0, prev - 1))
}
} catch (error) {
console.error('Failed to mark as read:', error)
}
}

useEffect(() => {
fetchNotifications()
const interval = setInterval(fetchNotifications, 30000) // Poll every 30s
return () => clearInterval(interval)
}, [])

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 px-1 min-w-[1.2rem] h-5 flex items-center justify-center text-[10px]"
>
{unreadCount > 9 ? '9+' : unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[380px] p-0">
<div className="flex items-center justify-between p-4 border-b border-border/40">
<h4 className="font-semibold text-sm">Notifications</h4>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={markAllAsRead} className="h-8 text-xs">
Mark all as read
</Button>
)}
</div>
<div className="max-h-[400px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
No notifications yet.
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={cn(
"p-4 border-b border-border/10 last:border-0 hover:bg-accent/50 transition-colors cursor-pointer relative",
!notification.is_read && "bg-primary/5"
)}
onClick={() => !notification.is_read && markAsRead(notification.id)}
>
{!notification.is_read && (
<div className="absolute top-4 right-4 h-2 w-2 rounded-full bg-primary" />
)}
<p className="font-medium text-sm pr-4">{notification.title}</p>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{notification.message}
</p>
<p className="text-[10px] text-muted-foreground mt-2">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</p>
</div>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
39 changes: 39 additions & 0 deletions lib/notifications/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { sql } from '@/lib/db';
import { NotificationPayload } from './types';
import { templates } from './templates';

export class NotificationService {
/**
* Sends a notification to a user via all enabled channels.
*/
static async send(payload: NotificationPayload) {
const template = templates[payload.type](payload.data);

// 1. Store in-app notification in the database
await this.sendToDatabase(payload, template);

// 2. Send email notification (placeholder)
await this.sendEmail(payload, template);

console.log(`[NotificationService] Notification sent to User #${payload.userId}: ${template.title}`);
}

private static async sendToDatabase(payload: NotificationPayload, template: { title: string; message: string }) {
try {
await sql`
INSERT INTO notifications (user_id, type, title, message, link)
VALUES (${payload.userId}, ${payload.type}, ${template.title}, ${template.message}, ${payload.link || null})
`;
} catch (error) {
console.error('[NotificationService] Failed to store notification in database:', error);
}
}

private static async sendEmail(payload: NotificationPayload, template: { title: string; message: string }) {
// Placeholder for email service integration (e.g., Resend, SendGrid)
// In a real app, we would fetch the user's email and send the message.
console.log(`[EMAIL SIMULATION] To: User #${payload.userId}`);
console.log(`[EMAIL SIMULATION] Subject: ${template.title}`);
console.log(`[EMAIL SIMULATION] Body: ${template.message}`);
}
}
24 changes: 24 additions & 0 deletions lib/notifications/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NotificationType, NotificationTemplate } from './types';

export const templates: Record<NotificationType, (data: any) => NotificationTemplate> = {
milestone_submitted: (data: { jobTitle: string; freelancerName: string }) => ({
title: 'Milestone Submitted',
message: `${data.freelancerName} has submitted a milestone for "${data.jobTitle}".`,
}),
funds_released: (data: { jobTitle: string; amount: string; currency: string }) => ({
title: 'Funds Released',
message: `Payment of ${data.amount} ${data.currency} for "${data.jobTitle}" has been released to your wallet.`,
}),
dispute_opened: (data: { jobTitle: string; reason: string }) => ({
title: 'Dispute Opened',
message: `A dispute has been opened for "${data.jobTitle}". Reason: ${data.reason}`,
}),
wallet_activity: (data: { action: string; amount: string; currency: string }) => ({
title: 'Wallet Activity',
message: `New activity detected: ${data.action} of ${data.amount} ${data.currency}.`,
}),
funds_received: (data: { jobTitle: string; amount: string; currency: string }) => ({
title: 'Funds Received in Escrow',
message: `A deposit of ${data.amount} ${data.currency} for "${data.jobTitle}" has been confirmed in escrow.`,
}),
};
18 changes: 18 additions & 0 deletions lib/notifications/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type NotificationType =
| 'milestone_submitted'
| 'funds_released'
| 'dispute_opened'
| 'wallet_activity'
| 'funds_received';

export interface NotificationPayload {
userId: number;
type: NotificationType;
data: Record<string, any>;
link?: string;
}

export interface NotificationTemplate {
title: string;
message: string;
}
18 changes: 18 additions & 0 deletions scripts/004-notifications.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Notification System Schema
-- Stores in-app alerts for users

CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- e.g., 'milestone_submitted', 'funds_released', 'dispute_opened', 'wallet_activity'
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
link VARCHAR(255), -- Optional URL for navigation
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Index for faster retrieval of user notifications
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read);
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at);
Loading
Loading