'use client'; import React from 'react'; import { useMdUp } from '../../hooks/ui/use-media-query'; import { cn } from '../../utils/cn'; import { useOptionalNotifications } from '../features/notifications/notifications-context'; import { TimeTrackerHeaderButton } from '../features/time-tracker'; import { LogOutIcon, OpenFrameLogo, OpenFrameText, UserIcon } from '../icons'; import { Menu01Icon, SearchIcon, XmarkIcon } from '../icons-v2-generated'; import { BellIcon } from '../icons-v2-generated/interface/bell-icon'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, SquareAvatar } from '../ui'; import { HeaderButton } from './header-button'; import { HeaderGlobalSearch } from './header-global-search'; import { TicketAlertsButton } from './ticket-alerts-button'; import { HeaderMingoButton } from './header-mingo-button'; import { HeaderOrganizationFilter } from './header-organization-filter'; import { TopNavigation } from './top-navigation'; export interface AppHeaderProps { showSearch?: boolean; onSearch?: (query: string) => void; showOrganizations?: boolean; organizations?: { id: string; name: string }[]; selectedOrgId?: string; onOrgChange?: (id: string) => void; showNotifications?: boolean; unreadCount?: number; /** Render the support-ticket alerts cell (`TicketAlertsButton`). * Attention-only: even when true it renders nothing unless a * `` is mounted, the viewer is signed in, AND * there are unread support replies. */ showTicketAlerts?: boolean; /** BASE path of the tickets surface (any nesting prefix allowed — * '/help-center/tickets', '/support/portal/tickets'). The cell builds * the SSOT deep link `?ticket=` for the newest-unread * ticket before navigating. */ ticketAlertsHref?: string; /** Host navigation (router push) — receives the FULL computed href. * Defaults to `window.location.assign`. */ onTicketAlerts?: (href: string) => void; /** Render the time-tracker button + popup. Requires wrapping the app in ``. */ showTimeTracker?: boolean; /** Render the "Mingo AI" launcher button (drawer-style trigger for an * in-layout `AppLayoutDrawer` hosting the chat panel). Defaults to off. */ showMingoAI?: boolean; /** Click handler for the Mingo AI button — typically toggles the drawer. */ onMingoAI?: () => void; /** Whether the Mingo drawer is currently open (visually pressed state). */ isMingoAIActive?: boolean; // User block showUser?: boolean; userName?: string; userEmail?: string; userAvatarUrl?: string | null; onProfile?: () => void; onLogout?: () => void; className?: string; /** Whether the mobile menu is open */ isMobileMenuOpen: boolean; /** Callback to toggle mobile menu */ onToggleMobileMenu?: () => void; /** * When true, all header controls are disabled and visually dimmed * EXCEPT the mobile burger menu toggle, which remains interactive. */ disabled?: boolean; /** * Draw placeholder cells instead of the live controls. * * Solves a problem this header cannot solve on its own: its mobile/desktop split * is decided by `useMdUp()`, which answers `undefined` until an effect has run — * and `?? false` turns that into "mobile". The first render is therefore always * the phone header (burger + wordmark, no side actions), which on a desktop load * is a visible flash before the real layout appears. It is also what a host has * to show while the user and the action flags are still loading. * * The placeholder decides mobile vs desktop in CSS instead, so it is correct at * every width on the very first paint, server-rendered included. */ loading?: boolean; /** * Shape of the trailing action cells `loading` reserves. * * Needed because the `show*` props are usually the very thing the host is still * waiting on — they are typically driven by feature flags or permissions, so during * `loading` they all read `false` and the placeholder collapses to whatever is * hardcoded on (often just the avatar), which looks nothing like the loaded header. * * Pass the cells the header settles on, in order — see `HeaderLoadingCell` for the * footprints. A bare number is accepted as shorthand for all-`'icon'`. * * Unlike the sidebar's rows, a cell that turns out not to exist is cheap here: the * cells are a right-aligned cluster in otherwise empty space, so one disappearing * shifts nothing else on the page. * * Defaults to the shape implied by the `show*` props, which is right for a host whose * header composition is known up front. */ loadingActionCells?: number | ReadonlyArray; } /** * A trailing header cell's footprint while loading — see `loadingActionCells`. * * The breakpoint-scoped members exist because several live cells are themselves * responsive, and a placeholder that ignores that is wider than the header it hands * off to on exactly the viewports where the cluster is tightest: * * - `'icon'` — a fixed 48/56px cell at every width (time tracker, notifications). * - `'icon-md'` — the same cell, from md only (the avatar: `isMdUp && showUser`). * - `'icon-lg'` — the same cell, from lg only (the organization filter, which the * live header renders `hidden lg:flex`). * - `'wide'` — the Mingo launcher: a 48px square below md, where the live button is * `iconOnly`, and the fixed 140px labelled cell from md. */ export type HeaderLoadingCell = 'icon' | 'icon-md' | 'icon-lg' | 'wide'; export const AppHeader = React.memo(function AppHeader({ showSearch, onSearch, showOrganizations, organizations = [], selectedOrgId, onOrgChange, showNotifications, unreadCount = 0, showTicketAlerts = false, ticketAlertsHref, onTicketAlerts, showTimeTracker = false, showMingoAI = false, onMingoAI, isMingoAIActive = false, showUser, userName, userEmail, userAvatarUrl, onProfile, onLogout, className, isMobileMenuOpen, onToggleMobileMenu, disabled = false, loading = false, loadingActionCells, }: AppHeaderProps) { const isMdUp = useMdUp() ?? false; // After the only hook above, so the hook order is identical in both branches. if (loading) { return ( ); } const dimmedClass = disabled ? 'pointer-events-none opacity-50' : ''; // Cells carry their own dividers in the unified TopNavigation model // (the shell no longer applies divide-x). const cellDivider = 'border-l border-ods-border'; return ( : } aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'} aria-expanded={isMobileMenuOpen} className="border-r border-ods-border" /> ) } logo={ !isMdUp && ( <> ) } logoClassName="gap-2" center={showSearch ? : undefined} sideActions={ <> {/* Mobile: Search button */} {showSearch && ( } aria-label="Search" className={cn('md:hidden', cellDivider, dimmedClass)} disabled={disabled} /> )} {/* Desktop: Organizations filter (carries its own border-l) */} {showOrganizations && ( )} {/* Time tracker button */} {showTimeTracker && } {/* Notifications button */} {showNotifications && ( )} {/* Support-ticket alerts (Help Center) — attention-only: renders nothing unless there are unread replies (and a is mounted). */} {showTicketAlerts && ( )} {isMdUp && showUser && ( } aria-label="User" disabled={disabled} className={cn( 'outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0', cellDivider, dimmedClass, )} /> {/* User info header section */}
{userName &&
{userName}
} {userEmail &&
{userEmail}
}
{/* Menu items */}
Profile Settings
Log Out
)} {/* Mingo AI launcher — anchored at the very end of the header. On mobile it collapses to an icon-only affordance (no "Mingo AI" wordmark). */} {showMingoAI && ( )} } /> ); }); interface NotificationsHeaderButtonProps { fallbackUnreadCount: number; disabled: boolean; dimmedClass: string; } function NotificationsHeaderButton({ fallbackUnreadCount, disabled, dimmedClass }: NotificationsHeaderButtonProps) { const ctx = useOptionalNotifications(); const hasUnread = (ctx?.unreadCount ?? fallbackUnreadCount) > 0; const isActive = ctx?.isOpen ?? false; const onClick = ctx?.toggle; return ( : } // Shared dot primitive on the cell — same markup every indicator // cell renders (see HeaderButton.showUnreadDot / ). showUnreadDot={!isActive && hasUnread} aria-label={isActive ? 'Close notifications' : 'Notifications'} onClick={onClick} isActive={isActive} disabled={disabled || !onClick} className={dimmedClass} /> ); } export default AppHeader; // Literal class strings — Tailwind's scanner needs to see them spelled out. const CELL_VISIBILITY: Record, string | undefined> = { icon: undefined, 'icon-md': 'hidden md:flex', 'icon-lg': 'hidden lg:flex', }; /** One trailing action cell placeholder — mirrors the live cells' 48/56px box. */ function HeaderCellSkeleton({ className }: { className?: string }) { return (
); } /** * Wide cell placeholder — mirrors `HeaderMingoButton` at both widths, which are * two different shapes: the live button gets `iconOnly={!isMdUp}`, so below md it * drops its wordmark and collapses to the same 48px square as any other cell, and * from md it is the fixed 140px labelled cell. Reserving 140px-worth of icon, * label, gap and padding on a phone would make the placeholder cluster ~70px * wider than the header it hands off to. */ function HeaderWideCellSkeleton() { return (
); } /** * `AppHeader`'s loading state — see `AppHeaderProps.loading`. * * Keep in sync with `AppHeader` above; a diverging placeholder makes the handoff * jump. Every breakpoint decision here is a Tailwind variant on purpose: this is the * one thing the live header cannot do (its split runs through `useMdUp()`), and it * is the whole reason this branch exists. */ function AppHeaderSkeleton({ showSearch, actionCells, className, }: { showSearch?: boolean; actionCells: number | ReadonlyArray; className?: string; }) { const cells: ReadonlyArray = typeof actionCells === 'number' ? Array.from({ length: Math.max(0, actionCells) }, () => 'icon' as const) : actionCells; return (
} logo={ <>
} // `md:hidden` belongs on the WRAPPER, not the placeholder inside it: // `TopNavigation` builds its padded logo zone whenever `logo` is truthy, and // that padding runs to 24px at md and 80px at lg. The live header passes // `false` here from md up, so the zone does not exist at all — leaving the // wrapper visible would inset the search field by 80px on desktop and shift // it back on handoff, which is the exact jump this branch removes. logoClassName="gap-2 md:hidden" // `TopNavigation` already hides the center zone below `centerBreakpoint`. center={showSearch ?
: undefined} sideActions={ <> {/* Mobile-only search trigger; from md the search lives in the center zone. */} {showSearch && } {cells.map((cell, i) => cell === 'wide' ? ( ) : ( ), )} } /> ); }