Initial commit
This commit is contained in:
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