initial commit

This commit is contained in:
2026-09-28 17:24:00 +05:30
commit 9706fcc520
213 changed files with 53142 additions and 0 deletions

View File

@@ -0,0 +1,824 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
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';
import { AssistantPanel } from './AssistantPanel';
import { DateScopePicker } from './DateScope';
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[];
/**
* 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;
/**
* A full-width strip between the header and the page.
*
* The setup walkthrough lives here. It has to sit in the shell rather than
* on a page because it crosses several: one step is on Profile, the next on
* Users, the next on Inventory — anything page-local would vanish the moment
* somebody followed it.
*/
banner?: ReactNode;
}
/**
* 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,
headerActions,
banner,
}: AppShellProps) {
const { user, signOut } = useAuth();
const { pathname } = useLocation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isAssistantOpen, setIsAssistantOpen] = useState(true);
const [isNavOpen, setIsNavOpen] = useState(false);
const isMobile = useIsMobile();
const menuRef = useRef<HTMLDivElement>(null);
/*
A new page starts at the top.
A single-page app does not reload, so the document keeps whatever scroll
offset the last page was left at: read Sales down to row 40, click Inventory,
and Inventory opens 2,000px down — usually past everything it has, so it
looks empty. Nothing in the app was resetting this.
INSTANT, deliberately, and it is the one place in the console that opts out
of the smooth scrolling `index.css` turns on. `behavior: 'smooth'` here would
animate those 2,000px on every navigation, which means a second of the new
page flying past before it settles — the page arriving late, rather than the
page arriving. Smooth is for a move you asked for within a page; a route
change is not one.
Keyed on `pathname` alone, not on the whole location: the branch scope and
the date range travel in the query string, and re-scoping a table you are
halfway down should leave you where you were.
*/
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}, [pathname]);
// 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. */}
{/*
Opaque, with no backdrop blur.
The blur cost more than it bought the moment a popover moved into this
bar. `backdrop-filter` makes an element a CONTAINING BLOCK for every
`position: fixed` descendant, and the design system's popovers are fixed
and CSS-anchor-positioned — so the date picker's calendar rendered
inside the header at 0×0 and could not be opened at all. The same
control worked perfectly two pixels lower, on the page.
A solid background is the fix rather than a hack around it: the bar sits
on a near-white page, so at 85% opacity plus blur it was already almost
opaque, and nothing here reads differently for losing it.
*/}
<header
style={{
position: 'sticky',
top: 0,
zIndex: 40,
background: 'var(--color-surface)',
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 }}>
{/* 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. */}
{/* The date filter, beside the profile and common to every page.
Rendered here rather than by each page so the two questions the
console asks — which shop, and when — are both answered in the
chrome, and so the answer survives navigation. */}
<DateScopePicker />
{headerActions}
{/* 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 */}
{/* 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. */}
{banner}
<div className="admin-body app-gutter">
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
{/* 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 ? (
<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' }} />
);
/**
* Up to two letters for the avatar. Never a dash.
*
* First name and last name where the account has them. Where it does not —
* plenty of rows on this backend carry neither — the email is used instead,
* split on the separators people actually put in addresses, so
* `ragul.kumar@shop.in` gives RK and `care@nearle.in` gives C.
*
* The old version returned an em dash for anything it could not parse, and that
* is what every Store Admin and Store user saw: their accounts have no first or
* last name, so the avatar was a dash on every page. A dash says nothing and
* looks like a bug, which is worse than one letter.
*/
function initials(name: string): string {
const trimmed = name.trim();
if (trimmed === '') return '?';
// An email is not a name. Take the part before the @ and read the words out
// of it — a full address would otherwise give the domain's letter as the
// second initial.
const source = trimmed.includes('@') ? (trimmed.split('@')[0] ?? trimmed) : trimmed;
const words = source
.split(/[\s._\-+]+/)
.map((word) => word.replace(/[^\p{L}\p{N}]/gu, ''))
.filter(Boolean);
if (words.length === 0) return '?';
const first = words[0]?.[0] ?? '';
const last = words.length > 1 ? (words[words.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.
*/
export 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>
);
}