fe integration for notification system with context, components, and API integration
This commit is contained in:
@@ -2,6 +2,8 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
|
||||
import { AppSidebar } from '@/components/layout/app-sidebar'
|
||||
import { BottomNav } from '@/components/layout/bottom-nav'
|
||||
import { ThemeToggle } from '@/components/layout/theme-toggle'
|
||||
import { NotificationBell } from '@/components/layout/notification-bell'
|
||||
import { NotificationProvider } from '@/contexts/notification-context'
|
||||
import { TopLoader } from '@/components/shared/top-loader'
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -10,21 +12,24 @@ export default function DashboardLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<TopLoader />
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
||||
<div className="ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
<BottomNav />
|
||||
</SidebarProvider>
|
||||
<NotificationProvider>
|
||||
<SidebarProvider>
|
||||
<TopLoader />
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<NotificationBell />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
<BottomNav />
|
||||
</SidebarProvider>
|
||||
</NotificationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/shared/empty-state'
|
||||
import { NotificationRow } from '@/components/layout/notification-bell'
|
||||
import { useNotifications } from '@/contexts/notification-context'
|
||||
import { getApiError } from '@/lib/api'
|
||||
import { notifications } from '@/lib/api/notifications'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import type { NotificationResponse } from '@/types/notifications'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
type Filter = 'all' | 'unread'
|
||||
|
||||
export function NotificationsClient() {
|
||||
const { recent, unreadCount, open, markAllRead, refresh } = useNotifications()
|
||||
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [items, setItems] = useState<NotificationResponse[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// The caller decides whether to show the loading state (it is true on first render).
|
||||
const load = useCallback(async (targetPage: number, reset: boolean) => {
|
||||
try {
|
||||
const result = await notifications.list({
|
||||
unreadOnly: filter === 'unread',
|
||||
page: targetPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
setItems(prev => (reset ? result.items : [...prev, ...result.items]))
|
||||
setPage(targetPage)
|
||||
setTotal(result.total)
|
||||
} catch (error) {
|
||||
toast.add({ title: 'Could not load notifications', description: getApiError(error), type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filter])
|
||||
|
||||
// Reload when the filter changes or a new notification is pushed in.
|
||||
const latestId = recent[0]?.id
|
||||
useEffect(() => { void load(1, true) }, [load, latestId])
|
||||
|
||||
async function handleOpen(n: NotificationResponse) {
|
||||
setItems(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x)))
|
||||
await open(n)
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
await markAllRead()
|
||||
await load(1, true)
|
||||
}
|
||||
|
||||
async function handleDelete(n: NotificationResponse) {
|
||||
try {
|
||||
await notifications.remove(n.id)
|
||||
setItems(prev => prev.filter(x => x.id !== n.id))
|
||||
setTotal(t => Math.max(0, t - 1))
|
||||
void refresh()
|
||||
} catch (error) {
|
||||
toast.add({ title: 'Could not delete notification', description: getApiError(error), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const hasMore = items.length < total
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Notifications</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{unreadCount > 0 ? `${unreadCount} unread` : 'You\'re all caught up'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-2xl border border-border p-0.5">
|
||||
{(['all', 'unread'] as const).map(f => (
|
||||
<Button
|
||||
key={f}
|
||||
variant={filter === f ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => { setLoading(true); setFilter(f) }}
|
||||
>
|
||||
{f === 'all' ? 'All' : 'Unread'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled={unreadCount === 0} onClick={() => { void handleMarkAllRead() }}>
|
||||
Mark all read
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-border">
|
||||
{items.length === 0 && !loading ? (
|
||||
<EmptyState
|
||||
title={filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
|
||||
description="Alerts about deliveries, stock, claims and orders will show up here."
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{items.map(n => (
|
||||
<div key={n.id} className="group relative">
|
||||
<NotificationRow notification={n} onOpen={item => { void handleOpen(item) }} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete notification"
|
||||
className="absolute top-2 right-2 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => { void handleDelete(n) }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<Button variant="outline" disabled={loading} onClick={() => { setLoading(true); void load(page + 1, false) }} className="self-center">
|
||||
{loading ? 'Loading…' : 'Load more'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NotificationsClient } from './_components/notifications-client'
|
||||
|
||||
export default function NotificationsPage() {
|
||||
return <NotificationsClient />
|
||||
}
|
||||
Reference in New Issue
Block a user