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
13 changes: 13 additions & 0 deletions app/api/notifications/[id]/read/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { markNotificationRead } from '@/lib/notifications/notifications-store'

export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const updated = await markNotificationRead(id)

if (!updated) {
return NextResponse.json({ error: 'Notification not found' }, { status: 404 })
}

return NextResponse.json({ notification: updated })
}
62 changes: 62 additions & 0 deletions app/api/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import {
createNotification,
getNotifications,
getUnreadCount,
markAllNotificationsRead,
} from '@/lib/notifications/notifications-store'

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
const countOnly = searchParams.get('countOnly') === 'true'

if (!userId) {
return NextResponse.json({ error: 'Missing userId' }, { status: 400 })
}

if (countOnly) {
const unread = await getUnreadCount(userId)
return NextResponse.json({ unread })
}

const notifications = await getNotifications(userId)
const unread = notifications.filter((n) => !n.isRead).length
return NextResponse.json({ notifications, unread })
}

export async function POST(request: NextRequest) {
const body = await request.json()

if (!body.userId || !body.title || !body.message || !body.category) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}

const notification = await createNotification({
userId: body.userId,
title: body.title,
message: body.message,
category: body.category,
priority: body.priority,
metadata: body.metadata,
})

return NextResponse.json({ notification }, { status: 201 })
}

/** PATCH /api/notifications?userId=… — mark all as read */
export async function PATCH(request: NextRequest) {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')

if (!userId) {
return NextResponse.json({ error: 'Missing userId' }, { status: 400 })
}

const count = await markAllNotificationsRead(userId)
return NextResponse.json({ markedRead: count })
}

export async function OPTIONS() {
return NextResponse.json({}, { status: 200 })
}
99 changes: 99 additions & 0 deletions app/api/notifications/stream/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { NextRequest } from 'next/server'
import { getNotifications } from '@/lib/notifications/notifications-store'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'

/**
* GET /api/notifications/stream?userId=…
*
* Server-Sent Events endpoint. The client receives:
* - An immediate "snapshot" event with all current notifications on connect.
* - A "ping" heartbeat every 20 s to keep the connection alive through proxies.
* - A "update" event whenever new notifications appear (polled every 5 s server-side).
*
* The client falls back to REST polling if SSE is unavailable (e.g. HTTP/1.1 proxies
* that buffer responses). See `useNotifications` hook.
*/
export async function GET(request: NextRequest) {
const userId = new URL(request.url).searchParams.get('userId')

if (!userId) {
return new Response('Missing userId', { status: 400 })
}

const encoder = new TextEncoder()

let lastCount = 0
let lastIds = new Set<string>()

const stream = new ReadableStream({
async start(controller) {
function send(event: string, data: unknown) {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
)
}

// Send initial snapshot immediately
try {
const notifications = await getNotifications(userId)
lastCount = notifications.filter((n) => !n.isRead).length
lastIds = new Set(notifications.map((n) => n.id))
send('snapshot', { notifications, unread: lastCount })
} catch {
send('error', { message: 'Failed to load notifications' })
}

// Heartbeat every 20 s
const pingInterval = setInterval(() => {
try {
controller.enqueue(encoder.encode(': ping\n\n'))
} catch {
clearInterval(pingInterval)
clearInterval(pollInterval)
}
}, 20_000)

// Poll for changes every 5 s and push diff to client
const pollInterval = setInterval(async () => {
try {
const notifications = await getNotifications(userId)
const newUnread = notifications.filter((n) => !n.isRead).length
const newIds = new Set(notifications.map((n) => n.id))

const hasNew = notifications.some((n) => !lastIds.has(n.id))
const unreadChanged = newUnread !== lastCount

if (hasNew || unreadChanged) {
lastCount = newUnread
lastIds = newIds
send('update', { notifications, unread: newUnread })
}
} catch {
// Store unavailable — skip this tick
}
}, 5_000)

// Clean up when the client disconnects
request.signal.addEventListener('abort', () => {
clearInterval(pingInterval)
clearInterval(pollInterval)
try {
controller.close()
} catch {
// already closed
}
})
},
})

return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no', // disable Nginx buffering
},
})
}
10 changes: 5 additions & 5 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Metadata, Viewport } from 'next'
import { Analytics } from '@vercel/analytics/next'
import { ThemeProvider } from '@/components/theme-provider'
import { KycProvider } from '@/contexts/kyc-context'
import CookieConsentBanner from '@/components/CookieConsentBanner'
import { NotificationProvider } from '@/contexts/notification-context'
import './globals.css'

export const metadata: Metadata = {
Expand Down Expand Up @@ -51,11 +51,11 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body className="font-sans antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
<SentryErrorBoundary>
<KycProvider>
<KycProvider>
<NotificationProvider>
{children}
</KycProvider>
</SentryErrorBoundary>
</NotificationProvider>
</KycProvider>
</ThemeProvider>
<CookieConsentBanner />
<Analytics />
Expand Down
20 changes: 20 additions & 0 deletions components/dashboard/dashboard-effects.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client'

/**
* DashboardEffects — mounts side-effects that need to run while the user
* is inside the dashboard, without cluttering DashboardLayout's JSX.
*
* Currently activates:
* - Stellar Horizon payment stream → pushes "payment received" notifications
*/

import { useStellarPaymentStream } from '@/hooks/use-stellar-payment-stream'

interface Props {
walletAddress?: string
}

export function DashboardEffects({ walletAddress }: Props) {
useStellarPaymentStream(walletAddress ?? null)
return null
}
4 changes: 4 additions & 0 deletions components/dashboard/dashboard-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { EthPriceTicker } from '@/components/dashboard/eth-price-ticker'
import { BalanceProvider } from '@/contexts/balance-context'

import { ConnectButton } from '@/components/Wallet'
import { NotificationDropdown } from '@/components/notifications/notification-dropdown'
import { DashboardEffects } from '@/components/dashboard/dashboard-effects'

interface DashboardLayoutProps {
children: React.ReactNode
Expand All @@ -18,6 +20,7 @@ interface DashboardLayoutProps {
export function DashboardLayout({ children, walletAddress }: DashboardLayoutProps) {
return (
<BalanceProvider walletAddress={walletAddress}>
<DashboardEffects walletAddress={walletAddress} />
<div className="min-h-screen bg-background">
{/* Header */}
<motion.header
Expand All @@ -37,6 +40,7 @@ export function DashboardLayout({ children, walletAddress }: DashboardLayoutProp
<div className="flex items-center gap-3">
<EthPriceTicker />
<ThemeToggle />
<NotificationDropdown />
<ConnectButton />
<Link href="/settings">
<Button variant="ghost" size="sm" id="nav-settings-btn">
Expand Down
2 changes: 2 additions & 0 deletions components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Link from 'next/link'
import { Drawer } from 'vaul'
import { ThemeToggle } from '@/components/theme-toggle'
import { ConnectButton } from '@/components/Wallet'
import { NotificationDropdown } from '@/components/notifications/notification-dropdown'
import { cn } from '@/lib/utils'

const navItems = [
Expand Down Expand Up @@ -78,6 +79,7 @@ export function Navbar() {

<div className="flex items-center gap-3">
<ThemeToggle />
<NotificationDropdown />
<ConnectButton />

{/* Hamburger — mobile only */}
Expand Down
Loading