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:
Matt Ciaccio
2026-05-04 22:54:06 +02:00
parent f5772ce318
commit e598cc0708
5 changed files with 609 additions and 146 deletions

View File

@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
@@ -13,9 +14,9 @@ import {
Receipt,
FileText,
FolderOpen,
Mail,
Bell,
Camera,
Globe,
Settings,
Shield,
Home,
@@ -32,13 +33,32 @@ import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
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 { 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 {
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
isSuperAdmin?: boolean;
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 {
@@ -103,15 +123,38 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
marinaRequired: true,
items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
{ href: `${base}/scan`, label: 'Scan receipt', icon: Camera },
{ 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',
marinaRequired: true,
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 },
],
},
@@ -185,6 +228,7 @@ function SidebarContent({
hasMarinaAccess,
hasResidentialAccess,
user,
ports,
onToggleCollapse,
}: {
collapsed: boolean;
@@ -194,6 +238,7 @@ function SidebarContent({
hasMarinaAccess: boolean;
hasResidentialAccess: boolean;
user?: SidebarProps['user'];
ports?: Port[];
/** When provided, renders the collapse toggle row above the user footer (desktop). */
onToggleCollapse?: () => void;
}) {
@@ -201,29 +246,61 @@ function SidebarContent({
const [adminExpanded, setAdminExpanded] = useState(true);
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 {
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 (
<TooltipProvider delayDuration={0}>
<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
className={cn(
'flex items-center gap-3 px-4 py-5 border-b border-[#474e66]',
collapsed && 'justify-center px-2',
'relative flex items-center justify-center border-b border-[#474e66]',
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">
<span className="text-white font-bold text-sm">PN</span>
</div>
{!collapsed && (
<div className="min-w-0">
<p className="text-white font-semibold text-sm leading-tight truncate">Port Nimara</p>
<p className="text-[#83aab1] text-xs truncate">Marina CRM</p>
</div>
<Image
src={LOGO_URL}
alt="Port Nimara"
width={collapsed ? 40 : 72}
height={collapsed ? 40 : 72}
className="rounded-full shadow-md ring-2 ring-white/20"
unoptimized
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>
@@ -276,57 +353,62 @@ function SidebarContent({
</nav>
</ScrollArea>
{/* Collapse toggle (desktop only) */}
{onToggleCollapse && (
<button
type="button"
onClick={onToggleCollapse}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
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')}>
{/* User footer - entire row is the trigger for the UserMenu so the
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 ? (
<Tooltip>
<TooltipTrigger asChild>
<Avatar className="w-8 h-8 cursor-pointer">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
U
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent side="right">User Profile</TooltipContent>
</Tooltip>
) : (
<div className="flex items-center gap-3">
<Avatar className="w-8 h-8 shrink-0 shadow-sm ring-2 ring-white/30">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{user?.name ?? 'User'}</p>
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
<UserMenu
align="start"
user={user}
ports={ports}
trigger={
<button
type="button"
aria-label="Open user menu"
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]"
>
{portRoles[0]?.role?.name ?? 'Staff'}
</Badge>
</div>
</div>
<Avatar className="w-8 h-8 cursor-pointer">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
</button>
}
/>
) : (
<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">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">
{user?.name ?? 'User'}
</p>
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
>
{humanizeRole(portRoles[0]?.role?.name)}
</Badge>
</div>
</button>
}
/>
)}
</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 toggleSidebar = useUIStore((s) => s.toggleSidebar);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
@@ -370,6 +452,7 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user }: SidebarProps)
hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess}
user={user}
ports={ports}
onToggleCollapse={toggleSidebar}
/>
</aside>