backend integration started
This commit is contained in:
107
src/components/ErrorBoundary.tsx
Normal file
107
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* The last line before a white page.
|
||||
*
|
||||
* React unmounts the entire tree when a render throws and nothing catches it.
|
||||
* With no boundary anywhere in the app, one bad field shape on one card takes
|
||||
* the whole console down to an empty document — no message, no route, nothing
|
||||
* to act on but the browser console. That is exactly how a POS-health response
|
||||
* typed as an array but delivered as an object presented itself: a blank screen
|
||||
* on sign-in, with the cause four layers down.
|
||||
*
|
||||
* So this is not decoration. It converts "the app is broken" into "this screen
|
||||
* is broken, and here is what it said", which is the difference between a bug
|
||||
* report and a guess.
|
||||
*
|
||||
* Deliberately a class: `getDerivedStateFromError` has no hook equivalent.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Names the area in the message — "the Console page", "the assistant". */
|
||||
area?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
override state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Kept as console output rather than sent anywhere: there is no error
|
||||
// reporting endpoint, and inventing one would be a network call nobody
|
||||
// asked for. The component stack is the part that is not in the throw.
|
||||
console.error('[nearle] render failed', error, info.componentStack);
|
||||
}
|
||||
|
||||
private reset = () => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
override render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
const area = this.props.area ?? 'this screen';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
margin: '48px auto',
|
||||
maxWidth: 620,
|
||||
padding: '28px 32px',
|
||||
borderRadius: 16,
|
||||
border: '1px solid #F1D3D3',
|
||||
background: '#FFFBFB',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600, color: '#8A1F1F' }}>
|
||||
Something on {area} failed to render
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
|
||||
The rest of the console is fine — this is one screen, not the whole app. The message
|
||||
below is what broke, and the full stack is in the browser console.
|
||||
</p>
|
||||
<pre
|
||||
style={{
|
||||
margin: '0 0 20px',
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
background: '#FFF1F1',
|
||||
color: '#7A2020',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{error.message || String(error)}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.reset}
|
||||
style={{
|
||||
padding: '9px 18px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid #D9C0C0',
|
||||
background: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,19 @@ export interface PageHeaderProps {
|
||||
* 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.
|
||||
* right-aligned and wrapping. Everything the page stacks below sits on a 24px
|
||||
* rhythm.
|
||||
*
|
||||
* There was a hairline rule under all of this, with 16px of padding above it
|
||||
* and another 12px below before the tabs — 28px of nothing plus a line, on
|
||||
* every page of all three consoles. The line was doing no work the whitespace
|
||||
* was not already doing: a page title set in the display face at that size is
|
||||
* separated from what follows by being a title. Removing it and closing the gap
|
||||
* gives every page back roughly 45px above the fold, which on a catalogue is
|
||||
* a row of products.
|
||||
*
|
||||
* The tabs' spacing lives here rather than at each call site, so the five pages
|
||||
* that have tabs cannot drift apart from each other again.
|
||||
*/
|
||||
export function PageHeader({ title, count, description, actions, tabs, isLive }: PageHeaderProps) {
|
||||
return (
|
||||
@@ -31,8 +41,6 @@ export function PageHeader({ title, count, description, actions, tabs, isLive }:
|
||||
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 }}>
|
||||
@@ -103,7 +111,7 @@ export function PageHeader({ title, count, description, actions, tabs, isLive }:
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
{tabs}
|
||||
{tabs ? <div style={{ marginTop: 4 }}>{tabs}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, X } from 'lucide-react';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ChevronDown, ChevronLeft, LogOut, Menu, X } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { ROLE_LABEL } from '@/auth/roles';
|
||||
@@ -87,29 +88,12 @@ export function AppShell({
|
||||
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.
|
||||
@@ -181,6 +165,7 @@ export function AppShell({
|
||||
/>
|
||||
</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
|
||||
@@ -243,75 +228,21 @@ export function AppShell({
|
||||
</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>
|
||||
{/* No global search.
|
||||
|
||||
There was a search box here with `⌘K` on it, and its `onSubmit`
|
||||
was `preventDefault()` and nothing else — it advertised a
|
||||
console-wide search that did not exist and had no endpoint
|
||||
behind it. The per-page search boxes on Sales, Users, Stores and
|
||||
the catalogue are real and stay. A control that looks like it
|
||||
works costs more trust than a missing one. */}
|
||||
|
||||
{headerActions}
|
||||
|
||||
<IconButton label="Notifications, 2 unread" hasUnread>
|
||||
<Bell size={16} />
|
||||
</IconButton>
|
||||
{/* No notification bell. It was labelled "2 unread" with the dot
|
||||
painted unconditionally, for every user on every page, forever —
|
||||
and it had no click handler and no notifications endpoint behind
|
||||
it anywhere in the API. */}
|
||||
|
||||
{/* Assistant button moved to floating pill */}
|
||||
|
||||
@@ -473,7 +404,14 @@ export function AppShell({
|
||||
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 />
|
||||
{/* Scoped to the page, not the shell: a page that throws should leave
|
||||
the nav, the account menu and the workspace switcher usable, so
|
||||
you can walk to a screen that works instead of reloading blind.
|
||||
Keyed by pathname so navigating away clears a caught error —
|
||||
without that, one broken page latches the whole outlet. */}
|
||||
<ErrorBoundary key={pathname} area="this page">
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
|
||||
{isAssistantOpen || isMobile ? (
|
||||
|
||||
@@ -16,9 +16,9 @@ const CONTEXT: Record<
|
||||
> = {
|
||||
'/nearle/stores': {
|
||||
page: 'Stores',
|
||||
title: 'Good afternoon',
|
||||
title: 'Nearle Buddy',
|
||||
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.',
|
||||
reading: 'Would cover the tenant directory — branch counts, status and per-tenant performance.',
|
||||
prompts: [
|
||||
'Which tenants have no branches?',
|
||||
'Who onboarded most recently?',
|
||||
@@ -30,14 +30,14 @@ const CONTEXT: Record<
|
||||
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.',
|
||||
reading: 'Would cover 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.',
|
||||
reading: 'Would cover the global catalogue and what this tenant has already imported.',
|
||||
prompts: [
|
||||
'Which products are already imported?',
|
||||
'Catalogue or spreadsheet?',
|
||||
@@ -52,7 +52,7 @@ const CONTEXT: Record<
|
||||
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.',
|
||||
reading: 'Would cover every branch — revenue by channel, stock health, till status and what is waiting on you.',
|
||||
prompts: [
|
||||
'Which branch is underperforming?',
|
||||
'Any tills not syncing?',
|
||||
@@ -64,7 +64,7 @@ const CONTEXT: Record<
|
||||
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.',
|
||||
reading: 'Would cover orders, counter bills and delivery progress across your branches.',
|
||||
prompts: [
|
||||
'Why is the cancel rate high?',
|
||||
'Which orders are stuck?',
|
||||
@@ -76,7 +76,7 @@ const CONTEXT: Record<
|
||||
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.',
|
||||
reading: 'Would cover your catalogue, per-branch stock levels and the stock requests waiting on you.',
|
||||
prompts: [
|
||||
'What is waiting for approval?',
|
||||
'Which products are unpublished?',
|
||||
@@ -88,7 +88,7 @@ const CONTEXT: Record<
|
||||
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.',
|
||||
reading: 'Would cover 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?',
|
||||
@@ -100,10 +100,10 @@ const CONTEXT: Record<
|
||||
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.',
|
||||
reading: 'Would cover revenue by channel and branch, product performance and stock movement.',
|
||||
prompts: [
|
||||
'Which products are slow moving?',
|
||||
'Compare my three branches',
|
||||
'Compare my branches',
|
||||
'Online versus counter revenue',
|
||||
'What is my inventory worth?',
|
||||
],
|
||||
@@ -114,7 +114,7 @@ const FALLBACK = {
|
||||
page: 'Console',
|
||||
title: 'Good afternoon',
|
||||
greeting: 'Ask about anything on this page.',
|
||||
reading: 'Reading this page.',
|
||||
reading: 'Would cover this page.',
|
||||
prompts: ['What needs attention?', 'Summarise this page'],
|
||||
};
|
||||
|
||||
@@ -295,6 +295,13 @@ export function AssistantPanel({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Disabled, not merely inert.
|
||||
|
||||
This composer used to accept text and enable a brand-purple send
|
||||
button the moment you typed — then swallow the submit. There is no
|
||||
assistant endpoint anywhere in `src/api`. A disabled field says the
|
||||
same thing honestly and costs nobody a message they thought they
|
||||
sent. */}
|
||||
<form
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
style={{
|
||||
@@ -323,8 +330,9 @@ export function AssistantPanel({
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={`Ask Buddy about ${context.page.toLowerCase()}…`}
|
||||
aria-label="Ask Nearle Buddy"
|
||||
disabled
|
||||
placeholder="Not connected yet"
|
||||
aria-label="Ask Nearle Buddy — not connected yet"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
@@ -343,18 +351,18 @@ export function AssistantPanel({
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Send message"
|
||||
disabled={draft.trim() === ''}
|
||||
disabled
|
||||
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)',
|
||||
background: 'var(--color-surface-sunken)',
|
||||
color: 'var(--color-ink-4)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
cursor: draft.trim() ? 'pointer' : 'default',
|
||||
cursor: 'default',
|
||||
transition: 'background .2s',
|
||||
}}
|
||||
>
|
||||
@@ -371,7 +379,7 @@ export function AssistantPanel({
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Buddy reads this page's data. Check anything you act on.
|
||||
Not connected yet — there is no assistant service behind this panel.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user