import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'; import { ErrorBoundary } from '@/components/ErrorBoundary'; import { ChevronDown, ChevronLeft, LogOut, Menu, X } from 'lucide-react'; import { useIsMobile } from '@/hooks/useIsMobile'; import { useAuth } from '@/auth/AuthContext'; import { ROLE_LABEL } from '@/auth/roles'; import { AssistantPanel } from './AssistantPanel'; export interface NavEntry { to: string; label: string; } /** One entry in the account menu's MANAGE group. */ export interface MenuEntry { to: string; label: string; icon: ReactNode; /** One line under the label, for an entry whose scope is not obvious. */ note?: string; } export interface AppShellProps { /** Destinations for the header tabs and the mobile sheet. */ nav: readonly NavEntry[]; /** Where the logo links to — the workspace's own landing page. */ home: string; /** Accessible name for the tab list, e.g. "Store Admin". */ navLabel: string; /** * An optional control between the logo and the tabs — the Store Admin's * branch selector lives here. It sits inside the header rather than on each * page because it scopes every page, and a control that moves between pages * reads as a different control each time. */ scopeControl?: ReactNode; /** * Setup destinations, listed inside the account menu rather than in the nav. * * This follows the old console's judgement, and its reasoning holds: setup is * configuration, not a place anyone works from day to day, and a nav slot * spent on it is a slot taken from a section that IS worked from. The account * menu is where "my workspace's setup" belongs, beside the account and * sign-out. * * Listed as individual destinations rather than one "Settings" entry for the * old console's other reason: a single entry lands everyone on the first * section and makes them click again, and the sections are what people come * here for. */ manageItems?: readonly MenuEntry[]; /** * Workspace-specific controls in the header, left of the notification bell. * * For things a shop reaches from anywhere and that open in place rather than * navigating — the store's QR code is the first. A page for it would have * been a fifth destination for something that is looked at, printed once, and * closed. */ headerActions?: ReactNode; /** * A full-width strip between the header and the page. * * The setup walkthrough lives here. It has to sit in the shell rather than * on a page because it crosses several: one step is on Profile, the next on * Users, the next on Inventory — anything page-local would vanish the moment * somebody followed it. */ banner?: ReactNode; } /** * The application chrome, shared by every workspace. * * Built to KROW's `AdminLayout` spec. Deliberately NOT a coloured slab: the * header is a 56px white-at-85% bar with a backdrop blur and a hairline bottom * border, carrying text tabs whose active state is accent-coloured text plus a * 2px accent bar sitting on that border. The repo's own reasoning for text tabs * over pills: several destinations means several competing shapes if each one * is a pill, and a console header should recede rather than compete with the * page. * * One component rather than one per workspace. The workspaces differ in exactly * three things — their destinations, their home, and whether they have a scope * control — so those are props. Copying four hundred lines of chrome per role * is how two headers drift apart and stop looking like one product. */ export function AppShell({ nav, home, navLabel, scopeControl, manageItems, headerActions, banner, }: AppShellProps) { const { user, signOut } = useAuth(); const { pathname } = useLocation(); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isAssistantOpen, setIsAssistantOpen] = useState(true); const [isNavOpen, setIsNavOpen] = useState(false); const isMobile = useIsMobile(); const menuRef = useRef(null); // Escape closes the account menu. A menu that can only be dismissed by // finding the trigger again is a trap for anyone on a keyboard, and this one // sits over the page rather than beside it. useEffect(() => { if (!isMenuOpen) return; function onKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') setIsMenuOpen(false); } function onMouseDown(event: MouseEvent) { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { setIsMenuOpen(false); } } window.addEventListener('keydown', onKeyDown); document.addEventListener('mousedown', onMouseDown); return () => { window.removeEventListener('keydown', onKeyDown); document.removeEventListener('mousedown', onMouseDown); }; }, [isMenuOpen]); return (
{/* Two elements, on purpose. The bar is full-bleed — the glass, the blur and the hairline run the whole width of the screen, because a header that stops short of the edge reads as a floating card, not as chrome. The ROW inside it is capped by `.app-gutter`, so the logo and nav sit on exactly the same left edge as the page title below them at every width, including a 2560px monitor where the body is centred. */}
{/* 24px tall, width auto — the reference pins the logo's height and lets the wordmark set its own width. */} Nearle {/* Below md the scope control moves into the navigation sheet. On a 390px phone the logo, the selector and the right-hand cluster add up to 503px and push the header 113px past the viewport, so the page scrolls sideways. The sheet gives the branch names room to be read in full, and every page states its own scope in the header line beneath, so the phone never leaves the operator guessing. */} {scopeControl ? (
{scopeControl}
) : null} {/* Below lg the tabs are gone, so a flexible spacer keeps the right cluster against the edge instead of bunched beside the logo. */}
{/* Text tabs. Active = accent text + a 2px accent bar on the header rule. */}
{/* No global search. There was a search box here with `⌘K` on it, and its `onSubmit` was `preventDefault()` and nothing else — it advertised a console-wide search that did not exist and had no endpoint behind it. The per-page search boxes on Sales, Users, Stores and the catalogue are real and stay. A control that looks like it works costs more trust than a missing one. */} {headerActions} {/* No notification bell. It was labelled "2 unread" with the dot painted unconditionally, for every user on every page, forever — and it had no click handler and no notifications endpoint behind it anywhere in the API. */} {/* Assistant button moved to floating pill */} {/* Avatar trigger + chevron, with the account menu below it. */}
{isMenuOpen ? (
{user?.name}
{user?.email}
{user ? ROLE_LABEL[user.role] : ''}
{manageItems && manageItems.length > 0 ? ( <>

Manage

{manageItems.map((entry) => ( setIsMenuOpen(false)} /> ))} ) : null} {/* When this code was built. If it does not match what you were told was delivered, you are looking at an old copy — which is not something any screen otherwise reveals. */}

Build {__BUILD_STAMP__}

) : null}
{/* Wrapped rather than styled inline: an inline `display` would win over the media query that hides this above the tabs breakpoint. */} setIsNavOpen(true)}>
{/* Body: a column on a phone so the assistant stacks under the page, a row from md where it becomes a side column. */} {banner}
{/* Scoped to the page, not the shell: a page that throws should leave the nav, the account menu and the workspace switcher usable, so you can walk to a screen that works instead of reloading blind. Keyed by pathname so navigating away clears a caught error — without that, one broken page latches the whole outlet. */}
{isAssistantOpen || isMobile ? ( setIsAssistantOpen(false)} isStacked={isMobile} /> ) : null}
{/* Floating Assistant Button */} {!isMobile && !isAssistantOpen ? ( ) : null} {isNavOpen ? ( setIsNavOpen(false)} pathname={pathname} /> ) : null}
); } /** * The navigation sheet, below lg. * * 288px from the right, opaque rather than glass — a translucent sheet over a * page of tables is unreadable — with the active item carried by a tinted fill * instead of the 2px underline, which has nothing to sit on here. */ function MobileNav({ nav, scopeControl, onClose, pathname, }: { nav: readonly NavEntry[]; scopeControl?: ReactNode; onClose: () => void; pathname: string; }) { return (
); } /** * One destination in the account menu. * * A `NavLink` rather than a button with `navigate()`: middle-click, ⌘-click and * "open in new tab" all work on a real anchor and none of them work on a * button, and a setup screen is exactly the kind of thing someone parks in a * second tab. */ function MenuLink({ entry, onNavigate }: { entry: MenuEntry; onNavigate: () => void }) { const [isHovered, setIsHovered] = useState(false); return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', borderRadius: 12, textDecoration: 'none', background: isHovered ? 'var(--color-surface-sunken)' : 'transparent', color: 'var(--color-ink-1)', transition: 'background .15s', }} > {entry.icon} {entry.label} {entry.note ? ( {entry.note} ) : null} ); } const Rule = () => (
); /** * Up to two letters for the avatar. Never a dash. * * First name and last name where the account has them. Where it does not — * plenty of rows on this backend carry neither — the email is used instead, * split on the separators people actually put in addresses, so * `ragul.kumar@shop.in` gives RK and `care@nearle.in` gives C. * * The old version returned an em dash for anything it could not parse, and that * is what every Store Admin and Store user saw: their accounts have no first or * last name, so the avatar was a dash on every page. A dash says nothing and * looks like a bug, which is worse than one letter. */ function initials(name: string): string { const trimmed = name.trim(); if (trimmed === '') return '?'; // An email is not a name. Take the part before the @ and read the words out // of it — a full address would otherwise give the domain's letter as the // second initial. const source = trimmed.includes('@') ? (trimmed.split('@')[0] ?? trimmed) : trimmed; const words = source .split(/[\s._\-+]+/) .map((word) => word.replace(/[^\p{L}\p{N}]/gu, '')) .filter(Boolean); if (words.length === 0) return '?'; const first = words[0]?.[0] ?? ''; const last = words.length > 1 ? (words[words.length - 1]?.[0] ?? '') : ''; return (first + last).toUpperCase(); } /** * A 32px icon-only control. * * `label` is required, not optional — an icon-only control with no accessible * name is a bug, so the API makes it impossible to omit. */ export function IconButton({ label, children, onClick, hasUnread, }: { label: string; children: ReactNode; onClick?: () => void; hasUnread?: boolean; }) { const [isHovered, setIsHovered] = useState(false); return ( ); }