825 lines
29 KiB
TypeScript
825 lines
29 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';
|
||
import { DateScopePicker } from './DateScope';
|
||
|
||
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);
|
||
|
||
/*
|
||
A new page starts at the top.
|
||
|
||
A single-page app does not reload, so the document keeps whatever scroll
|
||
offset the last page was left at: read Sales down to row 40, click Inventory,
|
||
and Inventory opens 2,000px down — usually past everything it has, so it
|
||
looks empty. Nothing in the app was resetting this.
|
||
|
||
INSTANT, deliberately, and it is the one place in the console that opts out
|
||
of the smooth scrolling `index.css` turns on. `behavior: 'smooth'` here would
|
||
animate those 2,000px on every navigation, which means a second of the new
|
||
page flying past before it settles — the page arriving late, rather than the
|
||
page arriving. Smooth is for a move you asked for within a page; a route
|
||
change is not one.
|
||
|
||
Keyed on `pathname` alone, not on the whole location: the branch scope and
|
||
the date range travel in the query string, and re-scoping a table you are
|
||
halfway down should leave you where you were.
|
||
*/
|
||
useEffect(() => {
|
||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||
}, [pathname]);
|
||
|
||
// 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. */}
|
||
{/*
|
||
Opaque, with no backdrop blur.
|
||
|
||
The blur cost more than it bought the moment a popover moved into this
|
||
bar. `backdrop-filter` makes an element a CONTAINING BLOCK for every
|
||
`position: fixed` descendant, and the design system's popovers are fixed
|
||
and CSS-anchor-positioned — so the date picker's calendar rendered
|
||
inside the header at 0×0 and could not be opened at all. The same
|
||
control worked perfectly two pixels lower, on the page.
|
||
|
||
A solid background is the fix rather than a hack around it: the bar sits
|
||
on a near-white page, so at 85% opacity plus blur it was already almost
|
||
opaque, and nothing here reads differently for losing it.
|
||
*/}
|
||
<header
|
||
style={{
|
||
position: 'sticky',
|
||
top: 0,
|
||
zIndex: 40,
|
||
background: 'var(--color-surface)',
|
||
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. */}
|
||
|
||
{/* The date filter, beside the profile and common to every page.
|
||
|
||
Rendered here rather than by each page so the two questions the
|
||
console asks — which shop, and when — are both answered in the
|
||
chrome, and so the answer survives navigation. */}
|
||
<DateScopePicker />
|
||
|
||
{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>
|
||
);
|
||
}
|