Initial commit

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

View File

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