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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user