Files
daily_console_web/src/components/shell/AppShell.tsx
2026-09-01 12:54:40 +05:30

782 lines
27 KiB
TypeScript

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<HTMLDivElement>(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 (
<div style={{ minHeight: '100vh' }}>
{/* 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. */}
<header
style={{
position: 'sticky',
top: 0,
zIndex: 40,
background: 'rgba(255,255,255,.85)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
borderBottom: '1px solid var(--color-line)',
}}
>
<div
className="app-gutter"
style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
<Link
to={home}
aria-label={`${navLabel} — home`}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexShrink: 0,
textDecoration: 'none',
borderRadius: 12,
}}
>
{/* 24px tall, width auto — the reference pins the logo's height and
lets the wordmark set its own width. */}
<img
src="/logo-wordmark.png"
alt="Nearle"
style={{ height: 24, width: 'auto', display: 'block' }}
/>
</Link>
{/* 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 ? (
<div className="show-from-md" style={{ flexShrink: 0 }}>
{scopeControl}
</div>
) : null}
{/* Below lg the tabs are gone, so a flexible spacer keeps the right
cluster against the edge instead of bunched beside the logo. */}
<div className="lg-hidden" style={{ flex: 1 }} />
{/* Text tabs. Active = accent text + a 2px accent bar on the header rule. */}
<nav
aria-label={navLabel}
className="show-from-lg"
style={{ alignItems: 'center', gap: 2, flex: 1, minWidth: 0 }}
>
{nav.map((entry) => {
const isActive = pathname.startsWith(entry.to);
return (
<NavLink
key={entry.to}
to={entry.to}
aria-current={isActive ? 'page' : undefined}
style={{
position: 'relative',
whiteSpace: 'nowrap',
borderRadius: 12,
padding: '6px 10px',
fontSize: 13,
lineHeight: '20px',
fontWeight: 500,
textDecoration: 'none',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
transition: 'color .2s cubic-bezier(.16,1,.3,1)',
}}
>
{entry.label}
{isActive ? (
<span
style={{
position: 'absolute',
left: 10,
right: 10,
bottom: -13,
height: 2,
borderRadius: 999,
background: 'var(--color-brand)',
}}
/>
) : null}
</NavLink>
);
})}
</nav>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{/* 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. */}
<div ref={menuRef} style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setIsMenuOpen((open) => !open)}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 6px 4px 4px',
borderRadius: 12,
border: 0,
background: isMenuOpen ? 'var(--color-surface-sunken)' : 'transparent',
cursor: 'pointer',
}}
>
<span
style={{
width: 28,
height: 28,
borderRadius: 999,
background: 'var(--color-brand)',
color: '#fff',
display: 'grid',
placeItems: 'center',
fontSize: 11,
fontWeight: 600,
}}
>
{initials(user?.name ?? '')}
</span>
<ChevronDown size={12} style={{ color: 'var(--color-ink-3)' }} />
</button>
{isMenuOpen ? (
<div
role="menu"
style={{
position: 'absolute',
right: 0,
top: 40,
width: 268,
background: 'var(--color-surface)',
border: '1px solid var(--color-line)',
borderRadius: 12,
boxShadow: '0 12px 28px -6px rgb(16 24 40 / .12)',
padding: 6,
zIndex: 50,
}}
>
<div style={{ padding: '8px 10px 10px' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-ink-1)' }}>
{user?.name}
</div>
<div style={{ fontSize: 11.5, color: 'var(--color-ink-4)' }}>{user?.email}</div>
<div style={{ marginTop: 6 }}>
<span
style={{
display: 'inline-block',
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
borderRadius: 999,
padding: '2px 8px',
fontSize: 11,
fontWeight: 600,
}}
>
{user ? ROLE_LABEL[user.role] : ''}
</span>
</div>
</div>
{manageItems && manageItems.length > 0 ? (
<>
<Rule />
<p
style={{
margin: 0,
padding: '6px 12px 4px',
fontSize: 10.5,
fontWeight: 700,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'var(--color-ink-4)',
}}
>
Manage
</p>
{manageItems.map((entry) => (
<MenuLink
key={entry.to}
entry={entry}
onNavigate={() => setIsMenuOpen(false)}
/>
))}
</>
) : null}
<Rule />
<button
type="button"
onClick={signOut}
role="menuitem"
style={{
display: 'flex',
width: '100%',
alignItems: 'center',
gap: 10,
padding: '9px 12px',
borderRadius: 12,
border: 0,
background: 'transparent',
// Sign out is the one destructive-adjacent item here, and
// it sits last so a mis-aimed click lands on nothing.
color: 'var(--color-error, #d64545)',
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
textAlign: 'left',
}}
>
<LogOut size={15} />
Sign out
</button>
{/* 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. */}
<p
style={{
margin: '6px 12px 2px',
fontSize: 10.5,
color: 'var(--color-ink-4)',
fontVariantNumeric: 'tabular-nums',
}}
>
Build {__BUILD_STAMP__}
</p>
</div>
) : null}
</div>
{/* Wrapped rather than styled inline: an inline `display` would win
over the media query that hides this above the tabs breakpoint. */}
<span className="lg-hidden">
<IconButton label="Open navigation" onClick={() => setIsNavOpen(true)}>
<Menu size={16} />
</IconButton>
</span>
</div>
</div>
</header>
{/* Body: a column on a phone so the assistant stacks under the page, a
row from md where it becomes a side column. */}
{banner}
<div className="admin-body app-gutter">
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
{/* 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. */}
<ErrorBoundary key={pathname} area="this page">
<Outlet />
</ErrorBoundary>
</main>
{isAssistantOpen || isMobile ? (
<AssistantPanel onClose={() => setIsAssistantOpen(false)} isStacked={isMobile} />
) : null}
</div>
{/* Floating Assistant Button */}
{!isMobile && !isAssistantOpen ? (
<button
type="button"
onClick={() => setIsAssistantOpen(true)}
style={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 50,
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '6px 12px 6px 6px',
borderRadius: 999,
background: 'var(--color-surface)',
border: '1px solid var(--color-line)',
boxShadow: '0 8px 24px -4px rgba(0,0,0,0.1), 0 4px 10px -2px rgba(0,0,0,0.05)',
cursor: 'pointer',
transition: 'transform 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-2px)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--color-surface)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
overflow: 'hidden',
}}
>
<img src="/icon-192.png" alt="Nearle logo" style={{ width: 24, height: 24, objectFit: 'contain', borderRadius: '50%' }} />
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-ink-1)' }}>
Nearle Buddy
</span>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 2,
borderRadius: 6,
border: '1px solid var(--color-line)',
color: 'var(--color-ink-3)',
}}
>
<ChevronLeft size={14} />
</div>
</button>
) : null}
{isNavOpen ? (
<MobileNav
nav={nav}
scopeControl={scopeControl}
onClose={() => setIsNavOpen(false)}
pathname={pathname}
/>
) : null}
</div>
);
}
/**
* 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 (
<div
role="dialog"
aria-modal="true"
aria-label="Navigation"
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
display: 'flex',
justifyContent: 'flex-end',
}}
>
<button
type="button"
aria-label="Close navigation"
onClick={onClose}
style={{
position: 'absolute',
inset: 0,
border: 0,
background: 'rgb(15 23 42 / .35)',
cursor: 'pointer',
}}
/>
<div
style={{
position: 'relative',
width: 288,
maxWidth: '85vw',
height: '100%',
background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-line)',
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottom: '1px solid var(--color-line)',
}}
>
<span style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 600 }}>
Menu
</span>
<IconButton label="Close navigation" onClick={onClose}>
<X size={16} />
</IconButton>
</div>
{scopeControl ? (
<div
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--color-line)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: 'var(--color-ink-4)',
}}
>
Showing
</span>
{scopeControl}
</div>
) : null}
<nav style={{ display: 'flex', flexDirection: 'column', padding: 8, gap: 2 }}>
{nav.map((entry) => {
const isActive = pathname.startsWith(entry.to);
return (
<NavLink
key={entry.to}
to={entry.to}
onClick={onClose}
style={{
padding: '10px 12px',
borderRadius: 12,
fontSize: 13.5,
fontWeight: 500,
textDecoration: 'none',
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-2)',
}}
>
{entry.label}
</NavLink>
);
})}
</nav>
</div>
</div>
);
}
/**
* 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 (
<NavLink
to={entry.to}
role="menuitem"
onClick={onNavigate}
onMouseEnter={() => 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',
}}
>
<span
style={{
width: 26,
height: 26,
borderRadius: 8,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: isHovered ? 'var(--color-brand-tint)' : 'var(--color-surface-sunken)',
color: isHovered ? 'var(--color-brand)' : 'var(--color-ink-3)',
transition: 'background .15s, color .15s',
}}
>
{entry.icon}
</span>
<span style={{ minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 500 }}>{entry.label}</span>
{entry.note ? (
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)' }}>
{entry.note}
</span>
) : null}
</span>
</NavLink>
);
}
const Rule = () => (
<div style={{ height: 1, background: 'var(--color-line)', margin: '5px 0' }} />
);
/**
* 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 (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
position: 'relative',
display: 'grid',
placeItems: 'center',
width: 32,
height: 32,
borderRadius: 12,
border: 0,
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
color: isHovered ? 'var(--color-ink-1)' : 'var(--color-ink-3)',
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{children}
{hasUnread ? (
<span
style={{
position: 'absolute',
right: 4,
top: 4,
width: 8,
height: 8,
borderRadius: 999,
background: 'var(--color-brand)',
boxShadow: '0 0 0 2px #fff',
}}
/>
) : null}
</button>
);
}