store user login

This commit is contained in:
2026-08-25 18:00:16 +05:30
parent 1dc582ba07
commit 517a99d577
62 changed files with 8059 additions and 650 deletions

View File

@@ -25,6 +25,15 @@ export interface KpiCardProps {
note?: string;
tone?: KpiTone;
icon?: ReactNode;
/**
* Accepted and ignored.
*
* The underline bar this used to drive was removed from the tile. Six call
* sites still pass it, so the prop stays declared to keep them compiling —
* it is not read. Either drop it from the callers or restore the bar; right
* now it is neither, and this comment is here so that is visible.
*/
fill?: number;
}
/**
@@ -39,17 +48,47 @@ export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardP
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}
<VStack gap={1} padding={2} style={{ minHeight: 108 }}>
{/* Icon first, then the label; the pill sits opposite it. Both are
fixed to this row so tiles line up whether or not they carry one. */}
{/* A fixed height, so a label that wraps to two lines (a long note
squeezes it) does not push that tile's value below its neighbours'. */}
<HStack justify="between" align="center" gap={1} style={{ minHeight: 32 }}>
<HStack gap={1} align="center" style={{ minWidth: 0 }}>
{icon ? (
<span style={{ color, display: 'flex', flex: 'none' }} aria-hidden>
{icon}
</span>
) : null}
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
>
{label}
</Text>
</HStack>
{note ? (
<span
style={{
flex: 'none',
maxWidth: '58%',
background: 'var(--color-slate-100)',
color: 'var(--color-slate-500)',
borderRadius: 12,
padding: '2px 8px',
fontSize: 10.5,
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{note}
</span>
) : null}
</HStack>
<HStack justify="center" align="center" style={{ flex: 1 }}>
@@ -61,24 +100,6 @@ export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardP
{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

@@ -6,13 +6,16 @@ import { HStack } from '@astryxdesign/core/HStack';
export interface SectionHeaderProps {
title: string;
/**
* Sits inline beside the title, not beneath it.
* Sits inline beside the title, not beneath it — and only when it carries a
* fact, such as a count.
*
* 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.
* It used to be required, on the theory that every section should say what it
* is showing. In practice that produced a line of editorial beside every
* heading — "how today is going", "a queue nobody can see from the shop
* floor" — which reads as noise once you have seen the page twice. The
* heading and the data under it say enough.
*/
note: string;
note?: string;
/** A control or a "View all" link, right-aligned. */
action?: ReactNode;
}
@@ -24,9 +27,11 @@ export function SectionHeader({ title, note, action }: SectionHeaderProps) {
<Heading level={2}>
{title}
</Heading>
<Text type="body" color="secondary">
{note}
</Text>
{note ? (
<Text type="body" color="secondary">
{note}
</Text>
) : null}
</HStack>
{action}
</HStack>

View File

@@ -1,6 +1,6 @@
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 { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, X } from 'lucide-react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAuth } from '@/auth/AuthContext';
import { ROLE_LABEL } from '@/auth/roles';
@@ -49,6 +49,15 @@ export interface AppShellProps {
* here for.
*/
manageItems?: readonly MenuEntry[];
/**
* Workspace-specific controls in the header, left of the notification bell.
*
* For things a shop reaches from anywhere and that open in place rather than
* navigating — the store's QR code is the first. A page for it would have
* been a fifth destination for something that is looked at, printed once, and
* closed.
*/
headerActions?: ReactNode;
}
/**
@@ -67,7 +76,14 @@ export interface AppShellProps {
* 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) {
export function AppShell({
nav,
home,
navLabel,
scopeControl,
manageItems,
headerActions,
}: AppShellProps) {
const { user, signOut } = useAuth();
const { pathname } = useLocation();
@@ -291,6 +307,8 @@ export function AppShell({ nav, home, navLabel, scopeControl, manageItems }: App
</kbd>
</form>
{headerActions}
<IconButton label="Notifications, 2 unread" hasUnread>
<Bell size={16} />
</IconButton>
@@ -733,7 +751,7 @@ function initials(name: string): string {
* `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({
export function IconButton({
label,
children,
onClick,

View File

@@ -1,6 +1,13 @@
import { useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
import {
DEFAULT_WIDTH,
expandedWidth,
MAX_WIDTH,
MIN_WIDTH,
useAssistantWidth,
} from './assistantWidth';
/** Per-route context, so the panel knows which page it is sitting beside. */
const CONTEXT: Record<
@@ -135,19 +142,57 @@ export function AssistantPanel({
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { width, setWidth, reset } = useAssistantWidth();
const [isDragging, setDragging] = useState(false);
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',
}
: {};
/**
* Three widths, in priority order: stacked (the phone layout owns it),
* expanded (the one-click half-screen), then whatever the operator dragged
* it to. The inline width beats the media queries in `index.css`, which is
* the point — those are the default, not the rule.
*
* Height is deliberately NOT set here. It was, briefly — measured off the
* page so a short page got a short panel — but that put a strip of page
* background under the panel on every screen that was not full. The column
* is full height, always, and `.assistant` owns that.
*/
const sizeStyle: React.CSSProperties = isStacked
? {}
: isExpanded
? { width: expandedWidth(window.innerWidth), maxWidth: 'none' }
: { width, maxWidth: 'none' };
return (
<aside aria-label="Nearle Buddy" className="assistant" style={expandedStyle}>
/* No `position` here. `.assistant` is `position: sticky` from md up, and an
inline `relative` silently beat it — which is what made the panel scroll
away with the page. Sticky is itself a containing block, so the resize
handle's `absolute` still anchors to it. */
<aside aria-label="Nearle Buddy" className="assistant" style={sizeStyle}>
{isStacked ? null : (
<ResizeHandle
width={width}
isDragging={isDragging}
onDragStart={() => {
setDragging(true);
// Dragging is a deliberate size, so it leaves the half-screen
// preset rather than fighting it.
setIsExpanded(false);
}}
onDrag={(next) => setWidth(next, { persist: false })}
onDragEnd={(next) => {
setDragging(false);
setWidth(next);
}}
onNudge={(delta) => {
setIsExpanded(false);
setWidth(width + delta);
}}
onReset={reset}
/>
)}
<div
style={{
height: '100%',
@@ -376,6 +421,126 @@ function Chip({ label, onSelect }: { label: string; onSelect: () => void }) {
);
}
/**
* The drag handle, on the panel's left edge.
*
* A real `separator` rather than a decorative strip: it takes focus, answers
* the arrow keys in 16px steps, Home and End go to the two limits, and Enter or
* a double-click restores the default. Resizing by mouse only would put the one
* control that fixes a cramped page out of reach of anyone not using one.
*
* The hit area is 10px wide while the line it draws is 2px — a 2px target is a
* target you miss, and the padding costs nothing because it sits in the gap
* between the page and the panel.
*/
function ResizeHandle({
width,
isDragging,
onDragStart,
onDrag,
onDragEnd,
onNudge,
onReset,
}: {
width: number;
isDragging: boolean;
onDragStart: () => void;
onDrag: (next: number) => void;
onDragEnd: (next: number) => void;
onNudge: (delta: number) => void;
onReset: () => void;
}) {
const [isHovered, setHovered] = useState(false);
const latest = useRef(width);
latest.current = width;
function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
onDragStart();
// The width is measured from the right edge of the window, so the panel
// tracks the pointer exactly however the page is scrolled.
const move = (moveEvent: PointerEvent) => {
const next = window.innerWidth - moveEvent.clientX;
latest.current = next;
onDrag(next);
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
document.body.style.removeProperty('user-select');
document.body.style.removeProperty('cursor');
onDragEnd(latest.current);
};
// Without these two the drag selects the text it passes over and the
// cursor flickers back to a caret every time it leaves the handle.
document.body.style.setProperty('user-select', 'none');
document.body.style.setProperty('cursor', 'col-resize');
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
const isActive = isDragging || isHovered;
return (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize Nearle Buddy"
aria-valuenow={Math.round(width)}
aria-valuemin={MIN_WIDTH}
aria-valuemax={MAX_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onDoubleClick={onReset}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
onNudge(16);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
onNudge(-16);
} else if (event.key === 'Home') {
event.preventDefault();
onNudge(MAX_WIDTH);
} else if (event.key === 'End') {
event.preventDefault();
onNudge(-MAX_WIDTH);
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onReset();
}
}}
title={`${Math.round(width)}px — drag to resize, double-click for ${DEFAULT_WIDTH}px`}
style={{
position: 'absolute',
top: 0,
left: -5,
width: 10,
height: '100%',
zIndex: 5,
cursor: 'col-resize',
display: 'flex',
justifyContent: 'center',
touchAction: 'none',
}}
>
<span
aria-hidden
style={{
width: 2,
height: '100%',
borderRadius: 2,
background: isActive ? 'var(--color-brand)' : 'transparent',
transition: isDragging ? 'none' : 'background .15s',
}}
/>
</div>
);
}
function PanelIcon({
label,
children,

View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect, useState } from 'react';
/**
* How wide Nearle Buddy is, remembered.
*
* The panel is a layout column, not an overlay — the page reflows beside it —
* so its width is a trade the operator makes against their own screen, and the
* right answer differs per person and per monitor. Someone on a 1280 laptop
* reading a wide table wants it narrow; someone on a 27" wants a conversation
* they can read.
*
* Kept in `localStorage` rather than in the session or the URL: it is a comfort
* setting for this browser on this machine, it should survive a reload and a
* sign-out, and it should NOT travel in a link — a pasted link that also
* resized the recipient's panel would be a surprise.
*/
const KEY = 'nearle.buddy.width.v1';
/** The CSS default, matched to `.assistant` in `index.css`. */
export const DEFAULT_WIDTH = 380;
/**
* The floor and ceiling, taken from KROW rather than invented.
*
* Its assistant column is 340-520 by drag with a 380 default, and the expanded
* state goes to 620 capped at 42% of the viewport. Matching those numbers is
* the point of having a design system: below 340 the prompt chips stop fitting
* on one line, and past 520 the page the panel is meant to be *about* loses its
* measure.
*/
export const MIN_WIDTH = 340;
export const MAX_WIDTH = 520;
/** The one-click expanded width, and the share of the viewport it may take. */
export const EXPANDED_WIDTH = 620;
export const MAX_VIEWPORT_SHARE = 0.42;
export function clampWidth(value: number, viewport: number): number {
const ceiling = Math.min(
MAX_WIDTH,
Math.max(MIN_WIDTH, Math.round(viewport * MAX_VIEWPORT_SHARE)),
);
if (!Number.isFinite(value)) return DEFAULT_WIDTH;
return Math.min(ceiling, Math.max(MIN_WIDTH, Math.round(value)));
}
/** The expanded width, under the same viewport share. */
export function expandedWidth(viewport: number): number {
return Math.min(EXPANDED_WIDTH, Math.round(viewport * MAX_VIEWPORT_SHARE));
}
function read(): number {
try {
const raw = window.localStorage.getItem(KEY);
if (raw === null) return DEFAULT_WIDTH;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : DEFAULT_WIDTH;
} catch {
// Private windows and blocked site data throw on access, not on read.
return DEFAULT_WIDTH;
}
}
export function useAssistantWidth() {
const [width, setWidthState] = useState<number>(() =>
typeof window === 'undefined' ? DEFAULT_WIDTH : clampWidth(read(), window.innerWidth),
);
const setWidth = useCallback((next: number, options?: { persist?: boolean }) => {
const clamped = clampWidth(next, window.innerWidth);
setWidthState(clamped);
if (options?.persist === false) return;
try {
window.localStorage.setItem(KEY, String(clamped));
} catch {
// Not being able to remember it is not a reason to refuse to resize it.
}
}, []);
const reset = useCallback(() => setWidth(DEFAULT_WIDTH), [setWidth]);
// A window narrowed after the width was set would otherwise leave the panel
// eating the page. Re-clamped on resize, but never written back — the stored
// preference is what they chose on a big screen and should return with it.
useEffect(() => {
function onResize() {
setWidthState((current) => clampWidth(current, window.innerWidth));
}
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return { width, setWidth, reset };
}