"use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { motion } from "framer-motion"; import { ArrowUpRightIcon, BookOpenIcon, CircleUserRoundIcon, Building2Icon, ChevronDownIcon, KanbanIcon, ListChecksIcon, Trash2Icon, LogOutIcon, MenuIcon, PanelLeftCloseIcon, PanelLeftOpenIcon, SearchIcon, SparklesIcon, SettingsIcon, ShieldIcon, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { CommandPalette } from "@/components/command-palette"; import { SHORTCUTS, useKeyboardNav } from "@/hooks/use-keyboard-nav"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { HiredMark } from "@/components/hired-mark"; import { Notifications, type Notice } from "@/components/notifications"; import { AssistantDrawer } from "@/components/assistant/assistant-drawer"; import { AskContext } from "@/components/assistant/ask"; import { UserAvatar } from "@/components/user-avatar"; import { logoutAction } from "@/server/actions"; import { MANUAL_URL } from "@/lib/links"; // Navigation only. Settings and Admin are account actions, so they live in the // profile menu at the top right rather than in the rail. // // The words here are the ones the welcome tour teaches, and they were not. // The tour's second card says "every job you apply to is a card, drag it along" // — and then the rail read "Pipeline", a word the tour never uses, next to // "CRM", a three-letter acronym this audience has never met. Labels only: the // routes stay /applications and /crm, so every link, saved view and bookmark // still works, and the command palette carries the old words as search aliases // for anyone who does know them. // // CRM is the one entry with children: it is two peer screens (companies and // people), and reaching the second one used to require landing on the first // and finding the tabs. The rail names both — but folded away until asked for, // because a permanently open branch makes a five-item rail read as seven and // buries Pipeline. The parent is still a link to /crm; the chevron beside it is // what opens the branch. In the collapsed rail the children fold into the // icon's tooltip-covered single link, and the tabs on the page take over. type NavItem = { href: string; label: string; icon: typeof KanbanIcon; children?: { href: string; label: string }[]; }; const NAV: NavItem[] = [ { href: "/", label: "Today", icon: ListChecksIcon }, { href: "/me", label: "Me", icon: CircleUserRoundIcon }, { href: "/crm", label: "People", icon: Building2Icon, children: [ { href: "/crm/companies", label: "Companies" }, { href: "/crm/contacts", label: "Contacts" }, ], }, { href: "/applications", label: "Board", icon: KanbanIcon }, ]; // The rail remembers whether you collapsed it. Read after mount so the server // and the first client render agree. const COLLAPSE_KEY = "hired:sidebar-collapsed"; /** Which nav branches are open. Same reasoning as the rail's own collapse. */ const BRANCH_KEY = "hired:nav-open-branches"; export type ShellUser = { name: string; email: string; role: string; photo: string }; export function Shell({ children, notices, user, assistant, }: { children: React.ReactNode; notices: Notice[]; user: ShellUser; /** * Whether an admin has put an API key in. False is the default and means the * button below is never rendered — an instance whose people connect their own * client should look exactly as it did before this feature landed, and a * disabled control that explains itself is still a control they did not want. */ assistant: boolean; }) { const canAdmin = user.role === "ADMIN" || user.role === "SUPER_ADMIN"; const pathname = usePathname(); const [paletteOpen, setPaletteOpen] = useState(false); // j/k through a list, / to search, n to create, ? for the sheet. One // implementation for every screen: a row opts in by tagging its link. const { showHelp, setShowHelp } = useKeyboardNav(); const [drawerOpen, setDrawerOpen] = useState(false); const [askOpen, setAskOpen] = useState(false); // A request another screen handed over ("mine this role"), waiting in the // drawer's box for them to read and send. const [askSeed, setAskSeed] = useState(null); const ask = useCallback((message: string) => { setAskSeed(message); setAskOpen(true); }, []); const [collapsed, setCollapsed] = useState(false); const [openBranches, setOpenBranches] = useState([]); // Navigating is the reason the drawer was opened, so arriving closes it. useEffect(() => { setDrawerOpen(false); }, [pathname]); useEffect(() => { setCollapsed(window.localStorage.getItem(COLLAPSE_KEY) === "1"); try { const stored = JSON.parse(window.localStorage.getItem(BRANCH_KEY) ?? "[]"); if (Array.isArray(stored)) setOpenBranches(stored.filter((v) => typeof v === "string")); } catch { // A corrupt value is not worth a broken sidebar; start closed. } }, []); const toggleBranch = (href: string) => { setOpenBranches((current) => { const next = current.includes(href) ? current.filter((value) => value !== href) : [...current, href]; window.localStorage.setItem(BRANCH_KEY, JSON.stringify(next)); return next; }); }; /** * Being inside a branch opens it, whatever was stored. Landing on * /crm/contacts from a link and finding the rail insisting Contacts is * hidden would be the rail arguing with the page. */ const branchOpen = (href: string) => openBranches.includes(href) || pathname.startsWith(href); const toggleCollapsed = () => { setCollapsed((value) => { const next = !value; window.localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0"); return next; }); }; useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key === "k" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); setPaletteOpen((open) => !open); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); // A resume is opened from Me, so the editor lights Me up rather than nothing. const isActive = (href: string) => href === "/" ? pathname === "/" : pathname.startsWith(href) || (href === "/me" && pathname.startsWith("/resumes")); return (
{/* Rail */}
{/* Top bar */}
{/* Five unlabelled icons told you nothing about where you were. The drawer holds the navigation; the bar just names the page. */} {NAV.find((item) => isActive(item.href))?.label ?? "Hired"}
{/* One button, beside the bell. Not a nav item: this is something you ask while looking at a screen, not a place you go. */} {assistant && ( )}
{ setDrawerOpen(false); setPaletteOpen(true); }} />
{children}
{assistant && ( setAskSeed(null)} /> )} Keyboard The keys this app answers to.
    {SHORTCUTS.map((shortcut) => (
  • {shortcut.keys} {shortcut.what}
  • ))}
); } /** * The phone's navigation. Everything the desktop rail holds, plus the account * links that live in the profile menu — because on a phone the profile menu is * a 28px avatar and "where is Settings" should not depend on finding it. * * Every link closes it on tap. The pathname effect above is still the backstop, * but it only fires once the server has answered — so on a slow connection a * tap looked like nothing had happened, with the menu sitting on top of the * screen it was navigating to. */ function MobileNav({ open, onOpenChange, isActive, canAdmin, onSearch, branchOpen, onToggleBranch, }: { open: boolean; onOpenChange: (open: boolean) => void; isActive: (href: string) => boolean; canAdmin: boolean; onSearch: () => void; /** Shared with the rail so the drawer and the sidebar never disagree. */ branchOpen: (href: string) => boolean; onToggleBranch: (href: string) => void; }) { // One Docs, and it is the manual. The in-app page that used to sit beside it // said the same things from the same tools array, and the menu should not ask // anybody to pick between two answers to one question. It lives on another // origin, so it opens in its own tab and the arrow says so. const secondary = [ { href: "/archive", label: "Archive", icon: Trash2Icon, external: false }, { href: MANUAL_URL, label: "Docs", icon: BookOpenIcon, external: true }, { href: "/settings", label: "Settings", icon: SettingsIcon, external: false }, ...(canAdmin ? [{ href: "/settings/admin", label: "Admin", icon: ShieldIcon, external: false }] : []), ]; return ( Menu
Hired
Your career, on the record
{secondary.map((item) => { const className = "text-muted-foreground flex h-11 items-center gap-3 rounded-lg px-3 text-[14px] font-medium transition-colors"; const body = ( <> {item.label} {item.external && } ); return item.external ? ( {body} ) : ( onOpenChange(false)} className={className} > {body} ); })}
); } function ProfileMenu({ user, canAdmin }: { user: ShellUser; canAdmin: boolean }) { const roleLabel = user.role === "SUPER_ADMIN" ? "Owner" : canAdmin ? "Admin" : "Member"; return (
{user.name || user.email}
{user.email}
{roleLabel}
{/* Docs is docs.hired.tools. It is on another origin, hence the arrow and the tab; the skills it used to carry are on Settings, because those files are served by this instance and nothing else can. */} Archive Docs Settings {canAdmin && ( Admin )}
); }