Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import { useState } from 'react';
import { Search } from 'lucide-react';
export interface ConsoleSearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
style?: React.CSSProperties;
}
export function ConsoleSearchBar({
value,
onChange,
placeholder = 'Search',
className,
style,
}: ConsoleSearchBarProps) {
const [isFocused, setIsFocused] = useState(false);
return (
<form
role="search"
className={className}
onSubmit={(event) => event.preventDefault()}
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
width: '100%',
...style,
}}
>
<Search
size={14}
style={{
position: 'absolute',
left: 10,
color: 'var(--color-ink-4)',
pointerEvents: 'none',
}}
/>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
aria-label={placeholder}
style={{
height: 32,
width: '100%',
borderRadius: 12,
border: `1px solid ${isFocused ? 'var(--color-brand)' : 'var(--color-line)'}`,
background: isFocused ? 'var(--color-surface)' : 'var(--color-surface-subtle)',
paddingLeft: 32,
paddingRight: 12, // Removed space for kbd since it's not present here
fontSize: 13,
color: 'var(--color-ink-1)',
outline: 'none',
transition: 'border-color .2s, background .2s',
boxShadow: 'none',
}}
/>
</form>
);
}

View File

@@ -0,0 +1,65 @@
import type { ReactNode } from 'react';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Spinner } from '@astryxdesign/core/Spinner';
import { HStack } from '@astryxdesign/core/HStack';
import { AlertTriangle, Inbox } from 'lucide-react';
import { errorMessage } from '@/api/client';
export interface DataStateProps {
isLoading: boolean;
error: unknown;
isEmpty: boolean;
/** What is missing, in the user's words — "No tenants yet". */
emptyTitle: string;
emptyDescription?: string;
emptyAction?: ReactNode;
children: ReactNode;
}
/**
* The four states every list has, in one place.
*
* Written as a wrapper rather than repeated per screen because the failure mode
* it prevents is a real one: pages that handle loading and success, and render
* a blank rectangle for empty and error.
*/
export function DataState({
isLoading,
error,
isEmpty,
emptyTitle,
emptyDescription,
emptyAction,
children,
}: DataStateProps) {
if (isLoading) {
return (
<HStack justify="center" padding={6}>
<Spinner size="md" label="Loading" />
</HStack>
);
}
if (error) {
return (
<EmptyState
title="That did not load"
description={errorMessage(error)}
icon={<AlertTriangle size={22} />}
/>
);
}
if (isEmpty) {
return (
<EmptyState
title={emptyTitle}
description={emptyDescription}
icon={<Inbox size={22} />}
actions={emptyAction}
/>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,53 @@
import { Text } from '@astryxdesign/core/Text';
import { HStack } from '@astryxdesign/core/HStack';
import { RefreshCw } from 'lucide-react';
export interface FreshnessProps {
/** `dataUpdatedAt` from the query that fed the figure. */
updatedAt: number | undefined;
isFetching?: boolean;
/** Bills sitting on a till that has not synced. Hidden when zero. */
unsynced?: number;
}
function clock(ms: number): string {
return new Date(ms).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
/**
* How old the number above this line is.
*
* Online orders land as they are placed; counter sales arrive on the console's
* 30-second refetch, and whatever is still sitting on an offline till has not
* arrived at all. So any figure blending the two is eventually consistent, and
* printing one confident number without saying when it was true is how a screen
* loses the room's trust.
*/
export function Freshness({ updatedAt, isFetching, unsynced }: FreshnessProps) {
return (
<HStack align="center" gap={1.5} wrap="wrap">
<HStack align="center" gap={0.5}>
<RefreshCw
size={12}
style={{
color: 'var(--color-slate-400)',
animation: isFetching ? 'spin 1s linear infinite' : undefined,
}}
/>
<Text type="body" size="xsm" color="secondary">
{updatedAt ? `as of ${clock(updatedAt)}` : 'not loaded yet'}
</Text>
</HStack>
{typeof unsynced === 'number' && unsynced > 0 ? (
<Text type="body" size="xsm" style={{ color: 'var(--color-warning, #b7860b)' }}>
· {unsynced} bill{unsynced === 1 ? '' : 's'} not yet synced
</Text>
) : null}
</HStack>
);
}

View File

@@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
export type KpiTone = 'accent' | 'success' | 'warning' | 'error' | 'neutral';
const TONE_COLOR: Record<KpiTone, string> = {
accent: 'var(--color-brand)',
success: 'var(--color-brand)',
warning: 'var(--color-brand)',
error: 'var(--color-brand)',
neutral: 'var(--color-brand)',
};
export interface KpiCardProps {
/** Small-caps label. Say what it is, not what it means. */
label: string;
value: string;
/**
* The sub-pill. This is where the number gets its meaning — "1 of 84 orders"
* says something "1.2%" does not.
*/
note?: string;
tone?: KpiTone;
icon?: ReactNode;
}
/**
* A KPI tile.
*
* Label and icon, a large tabular value, a muted sub-pill carrying the
* interpretation, and a 2px underline in the tile's tone. Sized to sit 4–6
* across rather than 4 — a console is glanced at all day, not read once.
*/
export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) {
const color = TONE_COLOR[tone];
return (
<Card padding={0} elevation="low">
<VStack gap={1} padding={2} style={{ position: 'relative', paddingBottom: 12, minHeight: 108 }}>
<HStack justify="between" align="start" gap={1}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
>
{label}
</Text>
{icon ? <span style={{ color, display: 'flex' }}>{icon}</span> : null}
</HStack>
<HStack justify="center" align="center" style={{ flex: 1 }}>
<Text
as="div"
hasTabularNumbers
style={{ color, fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 24, lineHeight: 1.1 }}
>
{value}
</Text>
</HStack>
{note ? (
<span
style={{
position: 'absolute',
bottom: 12,
left: 16,
background: 'var(--color-slate-100)',
color: 'var(--color-slate-500)',
borderRadius: 12,
padding: '2px 6px',
fontSize: 10.5,
fontWeight: 500,
}}
>
{note}
</span>
) : null}
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { VStack } from '@astryxdesign/core/VStack';
/**
* Two page measures, and the rule for choosing between them.
*
* KROW declares `--container-max: 80rem` for a page and a wider shell measure
* for the app frame, and the distinction matters: a form stretched across
* 1600px puts five inputs in a row and makes the eye travel further than the
* reading task deserves, while a table at 80rem wastes half the screen.
*
* 'reading' — forms, single flows, anything read top to bottom
* 'data' — tables, dashboards, catalogue grids
*/
export type PageMeasure = 'reading' | 'data';
const MAX_WIDTH: Record<PageMeasure, string> = {
reading: 'var(--container-page, 80rem)',
data: 'var(--container-admin, 102rem)',
};
export function PageBody({
measure = 'data',
children,
}: {
measure?: PageMeasure;
children: ReactNode;
}) {
return (
<VStack gap={3} width="100%" style={{ maxWidth: MAX_WIDTH[measure] }}>
{children}
</VStack>
);
}

View File

@@ -0,0 +1,109 @@
import type { ReactNode } from 'react';
export interface PageHeaderProps {
title: string;
/** A real count beside the name — "8 on record". Tabular, meta-coloured. */
count?: string;
/** One line of context. */
description?: string;
/** Right-aligned actions, wrapping. */
actions?: ReactNode;
/** An optional tabs row directly under the header rule. */
tabs?: ReactNode;
isLive?: boolean;
}
/**
* The page frame header, built to KROW's `AdminPage` spec.
*
* Title + count + a live pill on one line, a one-line subtitle beneath, actions
* right-aligned and wrapping, and — the part that makes pages feel like one
* product — a hairline rule beneath the whole thing at half-opacity, with 16px
* of padding above it. Everything the page stacks below sits on a 24px rhythm.
*/
export function PageHeader({ title, count, description, actions, tabs, isLive }: PageHeaderProps) {
return (
<>
<header
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 50%, transparent)',
paddingBottom: 16,
}}
>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h1
className="page-title"
style={{
margin: 0,
fontFamily: 'var(--font-display)',
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
{title}
</h1>
{count ? (
<span
style={{
fontSize: 13,
color: 'var(--color-ink-4)',
fontVariantNumeric: 'tabular-nums',
}}
>
{count}
</span>
) : null}
{isLive ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
borderRadius: 999,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
padding: '2px 10px',
fontSize: 11,
fontWeight: 600,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 999,
background: 'var(--color-brand)',
}}
/>
Live
</span>
) : null}
</div>
{description ? (
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
{description}
</p>
) : null}
</div>
{actions ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions}
</div>
) : null}
</header>
{tabs}
</>
);
}

View File

@@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { Heading } from '@astryxdesign/core/Heading';
import { Text } from '@astryxdesign/core/Text';
import { HStack } from '@astryxdesign/core/HStack';
export interface SectionHeaderProps {
title: string;
/**
* Sits inline beside the title, not beneath it.
*
* Required rather than optional on purpose: a bare heading tells the reader
* what a block is called but not what it is showing them, and every section
* in this console has something worth saying there.
*/
note: string;
/** A control or a "View all" link, right-aligned. */
action?: ReactNode;
}
export function SectionHeader({ title, note, action }: SectionHeaderProps) {
return (
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack align="center" gap={1.5} wrap="wrap">
<Heading level={2}>
{title}
</Heading>
<Text type="body" color="secondary">
{note}
</Text>
</HStack>
{action}
</HStack>
);
}

View File

@@ -0,0 +1,787 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
import { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, Sparkles, 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[];
}
/**
* 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 }: AppShellProps) {
const { user, signOut } = useAuth();
const { pathname } = useLocation();
const [query, setQuery] = useState('');
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isAssistantOpen, setIsAssistantOpen] = useState(true);
const [isNavOpen, setIsNavOpen] = useState(false);
const isMobile = useIsMobile();
const searchRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// ⌘K / Ctrl-K focuses search, the same global shortcut the reference uses.
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
searchRef.current?.focus();
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
const isSearchWide = isSearchFocused || query !== '';
// 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 }}>
{/* Search: 176px at rest, 256px once focused or filled. */}
<form
role="search"
className="show-from-md"
onSubmit={(event) => event.preventDefault()}
style={{
position: 'relative',
alignItems: 'center',
width: isSearchWide ? 256 : 176,
transition: 'width .2s cubic-bezier(.16,1,.3,1)',
}}
>
<Search
size={14}
style={{
position: 'absolute',
left: 10,
color: 'var(--color-ink-4)',
pointerEvents: 'none',
}}
/>
<input
ref={searchRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder="Search"
aria-label="Search the console"
style={{
height: 32,
width: '100%',
borderRadius: 12,
border: `1px solid ${isSearchFocused ? 'var(--color-brand)' : 'var(--color-line)'}`,
background: isSearchFocused
? 'var(--color-surface)'
: 'var(--color-surface-subtle)',
paddingLeft: 32,
paddingRight: 40,
fontSize: 13,
color: 'var(--color-ink-1)',
outline: 'none',
transition: 'border-color .2s, background .2s',
boxShadow: isSearchFocused ? '0 0 0 2px rgba(102,37,130,.15)' : 'none',
}}
/>
<kbd
style={{
position: 'absolute',
right: 8,
borderRadius: 4,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
padding: '0 4px',
fontSize: 10,
fontWeight: 500,
color: 'var(--color-ink-4)',
pointerEvents: 'none',
}}
>
⌘K
</kbd>
</form>
<IconButton label="Notifications, 2 unread" hasUnread>
<Bell size={16} />
</IconButton>
{/* 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. */}
<div className="admin-body app-gutter">
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
<Outlet />
</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' }} />
);
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '—';
const first = parts[0]?.[0] ?? '';
const last = parts.length > 1 ? (parts[parts.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.
*/
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>
);
}

View File

@@ -0,0 +1,414 @@
import { useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
/** Per-route context, so the panel knows which page it is sitting beside. */
const CONTEXT: Record<
string,
{ page: string; title: string; greeting: string; reading: string; prompts: string[] }
> = {
'/nearle/stores': {
page: 'Stores',
title: 'Good afternoon',
greeting: 'Every tenant on the platform, and which of them have branches sitting idle.',
reading: 'Reading the tenant directory — branch counts, status and per-tenant performance.',
prompts: [
'Which tenants have no branches?',
'Who onboarded most recently?',
'What needs attention?',
'Summarise platform growth',
],
},
'/nearle/onboard/tenant': {
page: 'Onboard tenant',
title: 'Provisioning a tenant',
greeting: 'Registering the enterprise also creates its primary Administrator account.',
reading: 'Reading the onboarding form — required fields, category and head-office address.',
prompts: ['What does provisioning create?', 'Which fields are required?', 'What happens next?'],
},
'/nearle/catalogue': {
page: 'Global catalogue',
title: 'Stocking a store',
greeting: 'The catalogue carries a price range, not a price — the store sets the real one.',
reading: 'Reading the global catalogue and what this tenant has already imported.',
prompts: [
'Which products are already imported?',
'Catalogue or spreadsheet?',
'What does re-importing do?',
'Which columns does the sheet need?',
],
},
/* ── Store Admin ──────────────────────────────────────────────────────── */
'/admin/console': {
page: 'Console',
title: 'Across your branches',
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
reading: 'Reading every branch — revenue by channel, stock health, till status and what is waiting on you.',
prompts: [
'Which branch is underperforming?',
'Any tills not syncing?',
'What needs my approval?',
'Where is stock running out?',
],
},
'/admin/sales': {
page: 'Sales',
title: 'Orders and deliveries',
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
reading: 'Reading orders, counter bills and delivery progress across your branches.',
prompts: [
'Why is the cancel rate high?',
'Which orders are stuck?',
'Compare online and counter sales',
'What is out for delivery?',
],
},
'/admin/inventory': {
page: 'Inventory',
title: 'Catalogue and stock',
greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.',
reading: 'Reading your catalogue, per-branch stock levels and the stock requests waiting on you.',
prompts: [
'What is waiting for approval?',
'Which products are unpublished?',
'What is low or out of stock?',
'How do I upload a product sheet?',
],
},
'/admin/users': {
page: 'Users & access',
title: 'Who can sign in',
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
reading: 'Reading your back-office directory and the till accounts at each branch.',
prompts: [
'What is the difference between the two?',
'How do I add a cashier?',
'Why can I not delete someone?',
'Who is inactive?',
],
},
'/admin/reports': {
page: 'Reports',
title: 'Revenue, sales and stock',
greeting: 'Fast and slow movers are the two lists that change what you order next.',
reading: 'Reading revenue by channel and branch, product performance and stock movement.',
prompts: [
'Which products are slow moving?',
'Compare my three branches',
'Online versus counter revenue',
'What is my inventory worth?',
],
},
};
const FALLBACK = {
page: 'Console',
title: 'Good afternoon',
greeting: 'Ask about anything on this page.',
reading: 'Reading this page.',
prompts: ['What needs attention?', 'Summarise this page'],
};
/**
* Nearle Buddy, as a layout column.
*
* Not an overlay and not a floating bubble: the panel is part of the page on
* every supported route, so the page reflows beside it rather than being
* covered. It opens by stating the most useful thing about the page it is next
* to, says what it read, and offers prompts scoped to that page.
*
* 380px by default, sticky under the 56px header, and its own height is the
* viewport minus 104px so it never pushes the page taller than the screen.
*/
export function AssistantPanel({
onClose,
isStacked = false,
}: {
onClose: () => void;
/** Below md the panel sits under the page rather than beside it. */
isStacked?: boolean;
}) {
const { pathname } = useLocation();
const [draft, setDraft] = useState('');
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
const context = (key ? CONTEXT[key] : undefined) ?? FALLBACK;
const expandedStyle: React.CSSProperties = isExpanded
? {
width: '50vw',
maxWidth: 'none',
}
: {};
return (
<aside aria-label="Nearle Buddy" className="assistant" style={expandedStyle}>
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: 16,
borderLeft: '1px solid var(--color-line)',
background: 'rgba(255,255,255,.72)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
boxShadow: '-4px 0 16px -2px rgb(16 24 32 / .06)',
}}
>
{/* Header: mark, name, and the current page as the subtitle. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<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',
flex: 'none',
}}
>
<img src="/icon-192.png" alt="Nearle logo" style={{ width: 24, height: 24, objectFit: 'contain', borderRadius: '50%' }} />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-ink-1)' }}>
Nearle Buddy
</div>
<div style={{ fontSize: 12, color: 'var(--color-ink-4)' }}>{context.page}</div>
</div>
{isStacked ? null : (
<>
<PanelIcon label="History">
<History size={14} />
</PanelIcon>
<PanelIcon label={isExpanded ? "Collapse" : "Expand"} onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</PanelIcon>
<PanelIcon label="Close Nearle Buddy" onClick={onClose}>
<PanelRightClose size={14} />
</PanelIcon>
</>
)}
</div>
{/* Greeting — top-aligned, because it is the first thing in a
conversation, not a splash screen. */}
<div>
<p
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 15,
fontWeight: 600,
lineHeight: 1.35,
color: 'var(--color-ink-1)',
}}
>
{context.title}
</p>
<p
style={{
margin: '4px 0 0',
fontSize: 13,
lineHeight: 1.6,
color: 'var(--color-ink-2)',
}}
>
{context.greeting}
</p>
<p
style={{
margin: '10px 0 0',
paddingTop: 10,
borderTop: '1px solid var(--color-line)',
fontSize: 12,
lineHeight: 1.6,
color: 'var(--color-ink-3)',
}}
>
{context.reading}
</p>
</div>
<div style={{ flex: 1 }} />
{/* Chips wrap, never scroll — a half-visible button at a scroller's edge
is a bug no amount of fade masking fixes. */}
<div role="group" aria-label="Suggested prompts" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{context.prompts.map((prompt) => (
<Chip key={prompt} label={prompt} onSelect={() => setDraft(prompt)} />
))}
</div>
<form
onSubmit={(event) => event.preventDefault()}
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 8,
padding: '8px 12px',
borderRadius: 20,
background: 'var(--color-surface)',
border: `1px solid ${isFocused ? 'rgba(102,37,130,.5)' : 'var(--color-line)'}`,
boxShadow: isFocused
? '0 8px 28px -8px rgba(102,37,130,.28)'
: '0 4px 16px -2px rgb(16 24 32 / .06)',
transition: 'all .2s cubic-bezier(.16,1,.3,1)',
}}
>
<textarea
ref={textareaRef}
rows={1}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
const el = event.target;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={`Ask Buddy about ${context.page.toLowerCase()}…`}
aria-label="Ask Nearle Buddy"
style={{
display: 'block',
width: '100%',
resize: 'none',
background: 'transparent',
border: 0,
outline: 'none',
padding: '2px 0',
fontSize: 13,
lineHeight: 1.6,
fontFamily: 'inherit',
color: 'var(--color-ink-1)',
maxHeight: 148,
}}
/>
<button
type="submit"
aria-label="Send message"
disabled={draft.trim() === ''}
style={{
width: 28,
height: 28,
borderRadius: 999,
border: 0,
flex: 'none',
background: draft.trim() ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: draft.trim() ? '#fff' : 'var(--color-ink-4)',
display: 'grid',
placeItems: 'center',
cursor: draft.trim() ? 'pointer' : 'default',
transition: 'background .2s',
}}
>
<ArrowUp size={14} />
</button>
</form>
<p
style={{
margin: 0,
fontSize: 10,
lineHeight: 1.4,
color: 'var(--color-ink-4)',
textAlign: 'center',
}}
>
Buddy reads this page&apos;s data. Check anything you act on.
</p>
</div>
</aside>
);
}
/**
* A prompt chip.
*
* `14px` radius is pinned rather than `999px`: 14 is what a fully-rounded
* corner already resolves to on a one-line chip, and pinning it means a chip
* that wraps to two lines keeps the same corner instead of turning into
* something that reads as a card.
*/
function Chip({ label, onSelect }: { label: string; onSelect: () => void }) {
const [isHovered, setIsHovered] = useState(false);
return (
<button
type="button"
onClick={onSelect}
title={label}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
maxWidth: '100%',
borderRadius: 14,
border: `1px solid ${isHovered ? 'rgba(102,37,130,.4)' : 'var(--color-line)'}`,
background: isHovered ? 'var(--color-brand-tint)' : 'var(--color-surface)',
color: isHovered ? 'var(--color-brand)' : 'var(--color-ink-2)',
padding: '4px 10px',
fontSize: 12,
lineHeight: '18px',
fontWeight: 500,
textAlign: 'left',
cursor: 'pointer',
transform: isHovered ? 'translateY(-1px)' : 'none',
boxShadow: isHovered
? '0 2px 10px 0 rgb(16 24 32 / .04)'
: '0 1px 2px 0 rgb(16 24 32 / .03)',
transition: 'all .2s cubic-bezier(.16,1,.3,1)',
}}
>
{label}
</button>
);
}
function PanelIcon({
label,
children,
onClick,
}: {
label: string;
children: React.ReactNode;
onClick?: () => void;
}) {
const [isHovered, setIsHovered] = useState(false);
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
width: 26,
height: 26,
borderRadius: 8,
border: 0,
display: 'grid',
placeItems: 'center',
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
color: isHovered ? 'var(--color-ink-1)' : 'var(--color-ink-4)',
cursor: 'pointer',
flex: 'none',
transition: 'background .2s, color .2s',
}}
>
{children}
</button>
);
}