Initial commit
This commit is contained in:
67
src/components/ConsoleSearchBar.tsx
Normal file
67
src/components/ConsoleSearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/components/DataState.tsx
Normal file
65
src/components/DataState.tsx
Normal 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}</>;
|
||||
}
|
||||
53
src/components/Freshness.tsx
Normal file
53
src/components/Freshness.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/KpiCard.tsx
Normal file
86
src/components/KpiCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/PageBody.tsx
Normal file
34
src/components/PageBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/PageHeader.tsx
Normal file
109
src/components/PageHeader.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
src/components/SectionHeader.tsx
Normal file
34
src/components/SectionHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
787
src/components/shell/AppShell.tsx
Normal file
787
src/components/shell/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
414
src/components/shell/AssistantPanel.tsx
Normal file
414
src/components/shell/AssistantPanel.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user