feat(layout): unified Inbox + UserMenu extraction
Replaces the topbar's separate AlertBell + NotificationBell with a single Inbox popover that tabs between alerts and notifications. NotificationBell keeps a popover-gate so it doesn't fire its list fetch when Inbox is mounted alongside it. Extracts the user dropdown into <UserMenu> and moves the port switcher + role label + theme toggle into the sidebar footer so the topbar can reclaim space for breadcrumbs and command search. Adds dedicated Insights / Receipts nav sections in the sidebar (scaffolds the website-analytics + upload-receipts entry points). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
260
src/components/layout/inbox.tsx
Normal file
260
src/components/layout/inbox.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified Inbox - merges the previous AlertBell + NotificationBell into
|
||||||
|
* a single bell with two tabs:
|
||||||
|
*
|
||||||
|
* System - port-wide operational alerts (system-generated, all
|
||||||
|
* users with `view_alerts` see the same list). Sourced from
|
||||||
|
* `useAlertList('open')` + `useAlertCount()`.
|
||||||
|
*
|
||||||
|
* Personal - per-user notifications (you signed something, someone
|
||||||
|
* @-mentioned you, your reminder fired, etc.). Sourced from
|
||||||
|
* `useNotifications` + the /api/v1/notifications endpoint.
|
||||||
|
*
|
||||||
|
* The bell icon shows a single combined unread count. The popover opens
|
||||||
|
* to whichever tab has unread items first; ties → Personal.
|
||||||
|
*
|
||||||
|
* Replaces the topbar's `<AlertBell />` + `<NotificationBell />`. The two
|
||||||
|
* old components stay in the tree (untouched) so anything that imports
|
||||||
|
* them directly (tests, deep-link pages) keeps working - they're simply
|
||||||
|
* no longer mounted in the topbar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Bell, ShieldAlert, Inbox as InboxIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useNotifications } from '@/hooks/use-notifications';
|
||||||
|
import { NotificationItem } from '@/components/notifications/notification-item';
|
||||||
|
import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card';
|
||||||
|
import { useAlertCount, useAlertList, useAlertRealtime } from '@/components/alerts/use-alerts';
|
||||||
|
|
||||||
|
interface NotificationListResponse {
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
link: string | null;
|
||||||
|
isRead: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabKey = 'personal' | 'system';
|
||||||
|
|
||||||
|
export function Inbox() {
|
||||||
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// ── System (alerts) ──
|
||||||
|
const { data: alertCount } = useAlertCount();
|
||||||
|
const { data: alertList, isLoading: alertsLoading } = useAlertList('open', open);
|
||||||
|
useAlertRealtime();
|
||||||
|
const systemTotal = alertCount?.total ?? 0;
|
||||||
|
const systemCritical = alertCount?.bySeverity.critical ?? 0;
|
||||||
|
const systemAlerts = alertList?.data ?? [];
|
||||||
|
const systemTop = systemAlerts.slice(0, 8);
|
||||||
|
|
||||||
|
// ── Personal (notifications) ──
|
||||||
|
const { unreadCount: personalUnread } = useNotifications();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: notifData, isLoading: notifLoading } = useQuery<NotificationListResponse>({
|
||||||
|
queryKey: ['notifications', 'list'],
|
||||||
|
queryFn: () => apiFetch('/api/v1/notifications?limit=20'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
// Only fetch the list when the popover opens - count stays live in the
|
||||||
|
// background via useNotifications' realtime hook.
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const notifications = notifData?.data ?? [];
|
||||||
|
|
||||||
|
const markRead = useMutation({
|
||||||
|
mutationFn: (id: string) => apiFetch(`/api/v1/notifications/${id}`, { method: 'PATCH' }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
});
|
||||||
|
const markAllRead = useMutation({
|
||||||
|
mutationFn: () => apiFetch('/api/v1/notifications/read-all', { method: 'POST' }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Combined badge + initial tab ──
|
||||||
|
const combined = systemTotal + personalUnread;
|
||||||
|
const initialTab = useMemo<TabKey>(() => {
|
||||||
|
// Default to whichever side has unread items; tie → Personal (most users
|
||||||
|
// care about their own pings before system-wide rumblings).
|
||||||
|
if (systemTotal > 0 && personalUnread === 0) return 'system';
|
||||||
|
return 'personal';
|
||||||
|
}, [systemTotal, personalUnread]);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
|
||||||
|
|
||||||
|
// Re-anchor to the unread side ONLY on the rising edge of `open` (i.e.
|
||||||
|
// when the popover actually opens). The previous version ran whenever
|
||||||
|
// `initialTab` changed too, which yanked the tab out from under the
|
||||||
|
// user any time a new socket event re-derived the initial tab while
|
||||||
|
// they were actively reading the other one.
|
||||||
|
const initialTabRef = useRef(initialTab);
|
||||||
|
initialTabRef.current = initialTab;
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setActiveTab(initialTabRef.current);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="relative"
|
||||||
|
aria-label={`Inbox${combined > 0 ? ` (${combined} unread)` : ''}`}
|
||||||
|
data-testid="inbox-bell"
|
||||||
|
>
|
||||||
|
<InboxIcon className="h-5 w-5" />
|
||||||
|
{combined > 0 ? (
|
||||||
|
<span
|
||||||
|
key={combined}
|
||||||
|
data-testid="inbox-bell-badge"
|
||||||
|
className={cn(
|
||||||
|
'absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white shadow-sm ring-2 ring-background animate-badge-pop',
|
||||||
|
// Critical system alerts win the colour war - they need the
|
||||||
|
// most attention. Otherwise the brand gradient.
|
||||||
|
systemCritical > 0 ? 'bg-destructive' : 'bg-gradient-brand',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{combined > 99 ? '99+' : combined}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent align="end" className="w-96 p-0">
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabKey)}>
|
||||||
|
<div className="flex items-center justify-between px-3 pt-3">
|
||||||
|
<TabsList className="h-8 grid grid-cols-2 w-full">
|
||||||
|
<TabsTrigger value="personal" className="text-xs gap-1.5">
|
||||||
|
<Bell className="h-3 w-3" aria-hidden />
|
||||||
|
Personal
|
||||||
|
{personalUnread > 0 ? (
|
||||||
|
<span className="ml-1 rounded-full bg-brand/15 px-1.5 text-[10px] font-semibold text-brand">
|
||||||
|
{personalUnread > 99 ? '99+' : personalUnread}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="system" className="text-xs gap-1.5">
|
||||||
|
<ShieldAlert className="h-3 w-3" aria-hidden />
|
||||||
|
System
|
||||||
|
{systemTotal > 0 ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-1 rounded-full px-1.5 text-[10px] font-semibold',
|
||||||
|
systemCritical > 0
|
||||||
|
? 'bg-destructive/15 text-destructive'
|
||||||
|
: 'bg-amber-500/15 text-amber-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{systemTotal > 99 ? '99+' : systemTotal}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Personal tab ── */}
|
||||||
|
<TabsContent value="personal" className="m-0">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
|
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Your notifications
|
||||||
|
</h4>
|
||||||
|
{personalUnread > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => markAllRead.mutate()}
|
||||||
|
disabled={markAllRead.isPending}
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
href={(portSlug ? `/${portSlug}/notifications` : '/notifications') as any}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<ScrollArea className="max-h-[400px]">
|
||||||
|
{notifLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-sm text-muted-foreground">
|
||||||
|
<Bell className="mb-2 h-8 w-8 opacity-30" aria-hidden />
|
||||||
|
No notifications
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={n.id}
|
||||||
|
notification={n}
|
||||||
|
onMarkRead={(id) => markRead.mutate(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── System tab ── */}
|
||||||
|
<TabsContent value="system" className="m-0">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
|
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Active alerts
|
||||||
|
</h4>
|
||||||
|
<Link
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<ScrollArea className="max-h-[400px]">
|
||||||
|
{alertsLoading ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">Loading…</div>
|
||||||
|
) : systemTop.length === 0 ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<AlertCardEmpty />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{systemTop.map((a) => (
|
||||||
|
<AlertCard key={a.id} alert={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -13,9 +14,9 @@ import {
|
|||||||
Receipt,
|
Receipt,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Mail,
|
|
||||||
Bell,
|
Bell,
|
||||||
Camera,
|
Camera,
|
||||||
|
Globe,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Home,
|
Home,
|
||||||
@@ -32,13 +33,32 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { UserMenu } from '@/components/layout/user-menu';
|
||||||
import type { UserPortRole } from '@/lib/db/schema/users';
|
import type { UserPortRole } from '@/lib/db/schema/users';
|
||||||
import type { Role } from '@/lib/db/schema/users';
|
import type { Role } from '@/lib/db/schema/users';
|
||||||
|
import type { Port } from '@/lib/db/schema/ports';
|
||||||
|
|
||||||
|
const LOGO_URL =
|
||||||
|
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
|
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
|
||||||
isSuperAdmin?: boolean;
|
isSuperAdmin?: boolean;
|
||||||
user?: { name: string; email: string };
|
user?: { name: string; email: string };
|
||||||
|
/** Ports the user has access to. Drives the footer port switcher. */
|
||||||
|
ports?: Port[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a snake_cased DB role identifier (e.g. "super_admin") into a human
|
||||||
|
* label ("Super Admin"). Empty/missing → "Staff" fallback.
|
||||||
|
*/
|
||||||
|
function humanizeRole(roleName: string | null | undefined): string {
|
||||||
|
if (!roleName) return 'Staff';
|
||||||
|
return roleName
|
||||||
|
.split('_')
|
||||||
|
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part))
|
||||||
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -103,15 +123,38 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
marinaRequired: true,
|
marinaRequired: true,
|
||||||
items: [
|
items: [
|
||||||
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
||||||
{ href: `${base}/scan`, label: 'Scan receipt', icon: Camera },
|
|
||||||
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
||||||
|
// Dedicated explainer page covers "Add to Home Screen" install
|
||||||
|
// + walkthrough; the mobile-only scanner UI itself lives at /scan
|
||||||
|
// and is reached via the install or the explainer page button.
|
||||||
|
{
|
||||||
|
href: `${base}/invoices/upload-receipts`,
|
||||||
|
label: 'How to upload receipts',
|
||||||
|
icon: Camera,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Insights',
|
||||||
|
marinaRequired: true,
|
||||||
|
items: [
|
||||||
|
// Marketing / Umami integration. Distinct from the main dashboard
|
||||||
|
// (which is sales-focused) so the audience and the metrics don't
|
||||||
|
// compete for visual real estate.
|
||||||
|
{
|
||||||
|
href: `${base}/website-analytics`,
|
||||||
|
label: 'Website analytics',
|
||||||
|
icon: Globe,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Communication',
|
title: 'Communication',
|
||||||
marinaRequired: true,
|
marinaRequired: true,
|
||||||
items: [
|
items: [
|
||||||
{ href: `${base}/email`, label: 'Email', icon: Mail },
|
// Email tab removed: we deferred building a full inbox/threading
|
||||||
|
// feature (would require Google OAuth + scope review + IMAP
|
||||||
|
// syncing infra). Reminders stays since it's already wired up.
|
||||||
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
|
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -185,6 +228,7 @@ function SidebarContent({
|
|||||||
hasMarinaAccess,
|
hasMarinaAccess,
|
||||||
hasResidentialAccess,
|
hasResidentialAccess,
|
||||||
user,
|
user,
|
||||||
|
ports,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
}: {
|
}: {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
@@ -194,6 +238,7 @@ function SidebarContent({
|
|||||||
hasMarinaAccess: boolean;
|
hasMarinaAccess: boolean;
|
||||||
hasResidentialAccess: boolean;
|
hasResidentialAccess: boolean;
|
||||||
user?: SidebarProps['user'];
|
user?: SidebarProps['user'];
|
||||||
|
ports?: Port[];
|
||||||
/** When provided, renders the collapse toggle row above the user footer (desktop). */
|
/** When provided, renders the collapse toggle row above the user footer (desktop). */
|
||||||
onToggleCollapse?: () => void;
|
onToggleCollapse?: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -201,29 +246,61 @@ function SidebarContent({
|
|||||||
const [adminExpanded, setAdminExpanded] = useState(true);
|
const [adminExpanded, setAdminExpanded] = useState(true);
|
||||||
const sections = buildNavSections(portSlug);
|
const sections = buildNavSections(portSlug);
|
||||||
|
|
||||||
|
// Pre-compute every nav href the sidebar offers across all sections so
|
||||||
|
// the active-state check can do longest-prefix-match. Without this,
|
||||||
|
// /invoices/upload-receipts would highlight both "Invoices" and "How to
|
||||||
|
// upload receipts" - every ancestor prefix matches.
|
||||||
|
const allHrefs = sections.flatMap((s) => s.items.map((i) => i.href));
|
||||||
|
|
||||||
function isActive(href: string, exact?: boolean): boolean {
|
function isActive(href: string, exact?: boolean): boolean {
|
||||||
if (exact) return pathname === href;
|
if (exact) return pathname === href;
|
||||||
return pathname.startsWith(href);
|
if (!pathname.startsWith(href)) return false;
|
||||||
|
// Active only if no other sidebar item has a strictly more-specific
|
||||||
|
// prefix match. That makes the highlight follow the URL into the
|
||||||
|
// deepest registered route (e.g. /invoices/upload-receipts wins over
|
||||||
|
// /invoices when both are sidebar items).
|
||||||
|
return !allHrefs.some((other) => other.length > href.length && pathname.startsWith(other));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar header showcases the Port Nimara logo as the visual anchor of
|
||||||
|
// the brand. Collapse toggle is a small overlay button so it doesn't
|
||||||
|
// compete with the logo for attention.
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<div className="flex flex-col h-full bg-[#1e2844]">
|
<div className="flex flex-col h-full bg-[#1e2844]">
|
||||||
{/* Logo area */}
|
{/* Brand header - logo centered (large when expanded, smaller when
|
||||||
|
collapsed). Collapse toggle floats top-right as a tiny chevron. */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-4 py-5 border-b border-[#474e66]',
|
'relative flex items-center justify-center border-b border-[#474e66]',
|
||||||
collapsed && 'justify-center px-2',
|
collapsed ? 'h-16 px-2' : 'h-24 px-4',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="shrink-0 w-8 h-8 rounded-md bg-[#3a7bc8] flex items-center justify-center">
|
<Image
|
||||||
<span className="text-white font-bold text-sm">PN</span>
|
src={LOGO_URL}
|
||||||
</div>
|
alt="Port Nimara"
|
||||||
{!collapsed && (
|
width={collapsed ? 40 : 72}
|
||||||
<div className="min-w-0">
|
height={collapsed ? 40 : 72}
|
||||||
<p className="text-white font-semibold text-sm leading-tight truncate">Port Nimara</p>
|
className="rounded-full shadow-md ring-2 ring-white/20"
|
||||||
<p className="text-[#83aab1] text-xs truncate">Marina CRM</p>
|
unoptimized
|
||||||
</div>
|
priority
|
||||||
|
/>
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-[#83aab1] hover:bg-[#171f35] hover:text-white transition-colors',
|
||||||
|
collapsed ? 'top-1' : 'top-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,41 +353,42 @@ function SidebarContent({
|
|||||||
</nav>
|
</nav>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Collapse toggle (desktop only) */}
|
{/* User footer - entire row is the trigger for the UserMenu so the
|
||||||
{onToggleCollapse && (
|
user can click their name/avatar to access Profile / Settings /
|
||||||
|
port-switcher / sign-out. The same UserMenu component drives the
|
||||||
|
top-right avatar dropdown, so the menu items stay consistent. */}
|
||||||
|
<div className={cn('border-t border-[#474e66] p-2', collapsed && 'flex justify-center')}>
|
||||||
|
{collapsed ? (
|
||||||
|
<UserMenu
|
||||||
|
align="start"
|
||||||
|
user={user}
|
||||||
|
ports={ports}
|
||||||
|
trigger={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleCollapse}
|
aria-label="Open user menu"
|
||||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e2844]"
|
||||||
className={cn(
|
|
||||||
'flex items-center w-full border-t border-[#474e66] px-3 py-2',
|
|
||||||
'text-[#83aab1] hover:bg-[#171f35] hover:text-white transition-colors',
|
|
||||||
collapsed ? 'justify-center' : 'justify-end gap-2',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{!collapsed && (
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em]">Collapse</span>
|
|
||||||
)}
|
|
||||||
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User footer */}
|
|
||||||
<div className={cn('border-t border-[#474e66] p-3', collapsed && 'flex justify-center')}>
|
|
||||||
{collapsed ? (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Avatar className="w-8 h-8 cursor-pointer">
|
<Avatar className="w-8 h-8 cursor-pointer">
|
||||||
<AvatarImage src={undefined} />
|
<AvatarImage src={undefined} />
|
||||||
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
|
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
|
||||||
U
|
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</TooltipTrigger>
|
</button>
|
||||||
<TooltipContent side="right">User Profile</TooltipContent>
|
}
|
||||||
</Tooltip>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3">
|
<UserMenu
|
||||||
|
align="start"
|
||||||
|
user={user}
|
||||||
|
ports={ports}
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Open user menu"
|
||||||
|
className="flex w-full items-center gap-3 rounded-md p-1.5 text-left transition-colors hover:bg-[#171f35] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e2844]"
|
||||||
|
>
|
||||||
<Avatar className="w-8 h-8 shrink-0 shadow-sm ring-2 ring-white/30">
|
<Avatar className="w-8 h-8 shrink-0 shadow-sm ring-2 ring-white/30">
|
||||||
<AvatarImage src={undefined} />
|
<AvatarImage src={undefined} />
|
||||||
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
|
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
|
||||||
@@ -318,15 +396,19 @@ function SidebarContent({
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-white text-sm font-medium truncate">{user?.name ?? 'User'}</p>
|
<p className="text-white text-sm font-medium truncate">
|
||||||
|
{user?.name ?? 'User'}
|
||||||
|
</p>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
|
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
|
||||||
>
|
>
|
||||||
{portRoles[0]?.role?.name ?? 'Staff'}
|
{humanizeRole(portRoles[0]?.role?.name)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +416,7 @@ function SidebarContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ portRoles, isSuperAdmin = false, user }: SidebarProps) {
|
export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: SidebarProps) {
|
||||||
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
||||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
@@ -370,6 +452,7 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user }: SidebarProps)
|
|||||||
hasMarinaAccess={hasMarinaAccess}
|
hasMarinaAccess={hasMarinaAccess}
|
||||||
hasResidentialAccess={hasResidentialAccess}
|
hasResidentialAccess={hasResidentialAccess}
|
||||||
user={user}
|
user={user}
|
||||||
|
ports={ports}
|
||||||
onToggleCollapse={toggleSidebar}
|
onToggleCollapse={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Plus, Moon, Sun, LogOut, User, Settings, Bell } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { useUIStore } from '@/stores/ui-store';
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
@@ -15,11 +15,10 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { PortSwitcher } from '@/components/layout/port-switcher';
|
|
||||||
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
|
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
|
||||||
import { CommandSearch } from '@/components/search/command-search';
|
import { CommandSearch } from '@/components/search/command-search';
|
||||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
import { Inbox } from '@/components/layout/inbox';
|
||||||
import { AlertBell } from '@/components/alerts/alert-bell';
|
import { UserMenu } from '@/components/layout/user-menu';
|
||||||
import type { Port } from '@/lib/db/schema/ports';
|
import type { Port } from '@/lib/db/schema/ports';
|
||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
@@ -30,31 +29,29 @@ interface TopbarProps {
|
|||||||
export function Topbar({ ports, user }: TopbarProps) {
|
export function Topbar({ ports, user }: TopbarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
const darkMode = useUIStore((s) => s.darkMode);
|
|
||||||
const toggleDarkMode = useUIStore((s) => s.toggleDarkMode);
|
|
||||||
|
|
||||||
const base = currentPortSlug ? `/${currentPortSlug}` : '';
|
const base = currentPortSlug ? `/${currentPortSlug}` : '';
|
||||||
|
|
||||||
function handleToggleDarkMode() {
|
|
||||||
toggleDarkMode();
|
|
||||||
document.documentElement.classList.toggle('dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-14 border-b border-border bg-background flex items-center gap-3 px-4 shrink-0">
|
// Three-column grid: breadcrumbs left, search center, actions right.
|
||||||
{/* Breadcrumbs / page title */}
|
// The brand logo lives in the sidebar header (per design feedback) so the
|
||||||
<div className="flex-1 min-w-0">
|
// topbar center is dedicated to the global search bar.
|
||||||
|
<header className="grid h-14 grid-cols-[minmax(0,1fr)_minmax(360px,640px)_minmax(0,1fr)] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||||
|
{/* LEFT: breadcrumbs / page title */}
|
||||||
|
<div className="min-w-0">
|
||||||
<Breadcrumbs />
|
<Breadcrumbs />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions row */}
|
{/* CENTER: global search - capped width and horizontally centered
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
inside its grid slot so it sits visually in the middle of the
|
||||||
{/* Global search — inline with dropdown results */}
|
topbar regardless of breadcrumb / action-row width. */}
|
||||||
|
<div className="flex items-center justify-center min-w-0">
|
||||||
|
<div className="w-full max-w-md mx-auto min-w-0">
|
||||||
<CommandSearch />
|
<CommandSearch />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Port switcher — hidden for single port */}
|
{/* RIGHT: action row */}
|
||||||
<PortSwitcher ports={ports} />
|
<div className="flex items-center gap-2 shrink-0 justify-end">
|
||||||
|
|
||||||
{/* + New dropdown */}
|
{/* + New dropdown */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -88,17 +85,21 @@ export function Topbar({ ports, user }: TopbarProps) {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Phase B operational alerts — distinct from user notifications */}
|
{/* Unified inbox - combines system alerts (operational) and personal
|
||||||
<AlertBell />
|
notifications (user-targeted) into a single bell with two tabs.
|
||||||
|
Replaces the previous AlertBell + NotificationBell pair. */}
|
||||||
{/* Notification bell — real-time via socket */}
|
<Inbox />
|
||||||
<NotificationBell />
|
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-6" />
|
<Separator orientation="vertical" className="h-6" />
|
||||||
|
|
||||||
{/* User menu */}
|
{/* User menu - single source of truth for the profile dropdown.
|
||||||
<DropdownMenu>
|
Same component is mounted in the sidebar footer so the menu
|
||||||
<DropdownMenuTrigger asChild>
|
items (incl. port switcher) stay consistent across triggers. */}
|
||||||
|
<UserMenu
|
||||||
|
align="end"
|
||||||
|
user={user}
|
||||||
|
ports={ports}
|
||||||
|
trigger={
|
||||||
<Button variant="ghost" size="icon" className="rounded-full">
|
<Button variant="ghost" size="icon" className="rounded-full">
|
||||||
<Avatar className="w-7 h-7 shadow-sm ring-2 ring-background">
|
<Avatar className="w-7 h-7 shadow-sm ring-2 ring-background">
|
||||||
<AvatarImage src={undefined} />
|
<AvatarImage src={undefined} />
|
||||||
@@ -107,56 +108,8 @@ export function Topbar({ ports, user }: TopbarProps) {
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
}
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
/>
|
||||||
<DropdownMenuLabel>
|
|
||||||
<div className="font-medium">{user?.name ?? 'My Account'}</div>
|
|
||||||
{user?.email && (
|
|
||||||
<div className="text-xs text-muted-foreground font-normal">{user.email}</div>
|
|
||||||
)}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
||||||
<DropdownMenuItem onClick={() => router.push(`${base}/settings/profile` as any)}>
|
|
||||||
<User className="w-4 h-4 mr-2" />
|
|
||||||
Profile
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
||||||
<DropdownMenuItem onClick={() => router.push(`${base}/settings` as any)}>
|
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
onClick={() => router.push(`${base}/notifications/preferences` as any)}
|
|
||||||
>
|
|
||||||
<Bell className="w-4 h-4 mr-2" />
|
|
||||||
Notification preferences
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={handleToggleDarkMode}>
|
|
||||||
{darkMode ? (
|
|
||||||
<>
|
|
||||||
<Sun className="w-4 h-4 mr-2" />
|
|
||||||
Light Mode
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Moon className="w-4 h-4 mr-2" />
|
|
||||||
Dark Mode
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => router.push('/api/auth/sign-out')}
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
|
||||||
Sign Out
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
159
src/components/layout/user-menu.tsx
Normal file
159
src/components/layout/user-menu.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified user menu - used by the topbar avatar AND the sidebar-footer
|
||||||
|
* profile row. Encapsulates:
|
||||||
|
* - Profile / Settings / Notification preferences links
|
||||||
|
* - Dark-mode toggle
|
||||||
|
* - Sign out
|
||||||
|
* - Inline port switcher (when the user has access to >1 port)
|
||||||
|
*
|
||||||
|
* Everywhere a "click my profile" affordance lives, it should mount this
|
||||||
|
* component. That keeps the menu items consistent regardless of which
|
||||||
|
* trigger the user reached for.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Moon, Sun, LogOut, User, Settings, Bell, Check, Building2 } from 'lucide-react';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import type { Port } from '@/lib/db/schema/ports';
|
||||||
|
|
||||||
|
interface UserMenuProps {
|
||||||
|
/** Element rendered as the dropdown trigger. Must be a single React node
|
||||||
|
* that can receive a click handler (asChild semantics). */
|
||||||
|
trigger: ReactNode;
|
||||||
|
/** "start" anchors menu under a sidebar-footer trigger (left edge);
|
||||||
|
* "end" anchors under a top-right avatar. Forwarded to Radix. */
|
||||||
|
align?: 'start' | 'end';
|
||||||
|
user?: { name: string; email: string };
|
||||||
|
/** Ports the user has access to. When length > 1, renders a port-switcher
|
||||||
|
* group inside the menu. When ≤ 1, the switcher is omitted. */
|
||||||
|
ports?: Port[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu({ trigger, align = 'end', user, ports }: UserMenuProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const currentPortId = useUIStore((s) => s.currentPortId);
|
||||||
|
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
|
const setPort = useUIStore((s) => s.setPort);
|
||||||
|
const darkMode = useUIStore((s) => s.darkMode);
|
||||||
|
const toggleDarkMode = useUIStore((s) => s.toggleDarkMode);
|
||||||
|
|
||||||
|
const base = currentPortSlug ? `/${currentPortSlug}` : '';
|
||||||
|
const showPortSwitcher = ports && ports.length > 1;
|
||||||
|
|
||||||
|
function handleToggleDarkMode() {
|
||||||
|
toggleDarkMode();
|
||||||
|
document.documentElement.classList.toggle('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePortChange(port: Port) {
|
||||||
|
if (port.id === currentPortId) return;
|
||||||
|
setPort(port.id, port.slug);
|
||||||
|
// All cached queries are port-scoped - invalidate so they refetch with
|
||||||
|
// the new X-Port-Id header.
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
router.push(`/${port.slug}/dashboard` as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align={align} className="w-60">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="font-medium">{user?.name ?? 'My Account'}</div>
|
||||||
|
{user?.email && (
|
||||||
|
<div className="text-xs text-muted-foreground font-normal">{user.email}</div>
|
||||||
|
)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{showPortSwitcher && (
|
||||||
|
<>
|
||||||
|
{/* Port list lives in a submenu so the user has to actively hover/
|
||||||
|
click "Switch port" to see them - the main menu stays compact
|
||||||
|
regardless of how many ports the operator has access to. */}
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Building2 className="w-4 h-4 mr-2" />
|
||||||
|
Switch port
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent className="w-56">
|
||||||
|
{ports!.map((port) => {
|
||||||
|
const active = port.id === currentPortId;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={port.id}
|
||||||
|
onClick={() => handlePortChange(port)}
|
||||||
|
className={active ? 'bg-accent/40' : undefined}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{port.name}</span>
|
||||||
|
{active && <Check className="ml-2 h-4 w-4 text-brand" aria-hidden />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`${base}/settings/profile` as any)}>
|
||||||
|
<User className="w-4 h-4 mr-2" />
|
||||||
|
Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`${base}/settings` as any)}>
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onClick={() => router.push(`${base}/notifications/preferences` as any)}
|
||||||
|
>
|
||||||
|
<Bell className="w-4 h-4 mr-2" />
|
||||||
|
Notification preferences
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleToggleDarkMode}>
|
||||||
|
{darkMode ? (
|
||||||
|
<>
|
||||||
|
<Sun className="w-4 h-4 mr-2" />
|
||||||
|
Light Mode
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Moon className="w-4 h-4 mr-2" />
|
||||||
|
Dark Mode
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => router.push('/api/auth/sign-out')}
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
Sign Out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { Bell } from 'lucide-react';
|
import { Bell } from 'lucide-react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
@@ -27,11 +28,18 @@ interface NotificationListResponse {
|
|||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const { unreadCount } = useNotifications();
|
const { unreadCount } = useNotifications();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
// Track popover open state so we only fire the list fetch when the user
|
||||||
|
// actually opens the bell. Without this, an instance of NotificationBell
|
||||||
|
// mounted alongside <Inbox /> would populate the same ['notifications',
|
||||||
|
// 'list'] cache key without the gating Inbox carefully applies, defeating
|
||||||
|
// Inbox's enabled-on-open optimization.
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<NotificationListResponse>({
|
const { data, isLoading } = useQuery<NotificationListResponse>({
|
||||||
queryKey: ['notifications', 'list'],
|
queryKey: ['notifications', 'list'],
|
||||||
queryFn: () => apiFetch('/api/v1/notifications?limit=20'),
|
queryFn: () => apiFetch('/api/v1/notifications?limit=20'),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
|
||||||
const markReadMutation = useMutation({
|
const markReadMutation = useMutation({
|
||||||
@@ -52,7 +60,7 @@ export function NotificationBell() {
|
|||||||
const notifications = data?.data ?? [];
|
const notifications = data?.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="relative">
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
|
|||||||
Reference in New Issue
Block a user