update the adminpage ui

This commit is contained in:
2026-09-25 16:29:41 +05:30
parent 91e4db8217
commit b36a30385c
168 changed files with 10535 additions and 1008 deletions

View File

@@ -0,0 +1,139 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Token} from '@astryxdesign/core/Token';
import {Link} from '@astryxdesign/core/Link';
import {List, ListItem} from '@astryxdesign/core/List';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {useThemeMode} from '@/shared/providers/ThemeModeProvider';
import type {ThemeMode} from '@/shared/theme/themeMode';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {ActiveSessionsPanel} from '@/features/settings/components/ActiveSessionsPanel';
import {AdminPageHeader} from './common/AdminPageHeader';
import {PendingIntegration} from './common/PendingIntegration';
/**
* The platform admin's own account — Profile and Settings.
*
* ── Only what the platform can answer for an admin ───────────────────────
* The profile is the signed-in session: id, name, email, role. Nothing is
* editable, because the only profile write (`PATCH /api/settings/profile`) is
* tenant-scoped and an admin has no tenant.
*
* Settings has two live parts: appearance (a cookie this app owns) and active
* sessions (`/api/auth/sessions`, scoped to the person, not a tenant). Password
* change and two-factor have no platform endpoint, so they say so rather than
* render a form that saves nowhere — the merchant Security page's password
* form and 2FA switch are exactly that, and are not reused here.
*/
export function AdminProfile() {
const {user} = useSession();
if (!user) return null;
const name = user.name || user.email;
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title="Profile"
subtitle="Your platform admin account."
actions={<Link href="/admin/settings">Settings</Link>}
/>
<Card padding={6}>
<HStack gap={4} vAlign="center" className="min-w-0">
<Avatar name={name} size="xl" tooltip={false} />
<VStack gap={1} className="min-w-0">
<Text size="lg" weight="semibold" className="truncate">
{name}
</Text>
<Text size="sm" color="secondary" className="truncate">
{user.email}
</Text>
<HStack>
<Token size="sm" label="Platform admin" />
</HStack>
</VStack>
</HStack>
</Card>
<StaticPanel
title="Account details"
subtitle="As the platform returned them at sign-in."
>
<List>
<ListItem label="Name" endContent={<Text size="sm">{user.name || '—'}</Text>} />
<ListItem label="Email" endContent={<Text size="sm">{user.email}</Text>} />
<ListItem label="Role" endContent={<Text size="sm">Platform admin</Text>} />
<ListItem
label="Scope"
description="A platform admin belongs to no merchant and administers all of them."
endContent={<Text size="sm">All merchants</Text>}
/>
<ListItem
label="Account ID"
endContent={
<Text size="sm" color="secondary">
{user.id}
</Text>
}
/>
</List>
</StaticPanel>
<StaticPanel title="Edit profile">
<PendingIntegration
icon="settings"
title="Name and email cannot be changed here yet"
description="The platform's only profile update is scoped to a merchant, and a platform admin has none. An admin endpoint for the signed-in account is needed."
/>
</StaticPanel>
</VStack>
);
}
export function AdminSettings() {
const {mode, setMode} = useThemeMode();
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title="Settings"
subtitle="Appearance and sign-in security for your platform admin account."
actions={<Link href="/admin/profile">Profile</Link>}
/>
<StaticPanel
title="Appearance"
subtitle="Saved in this browser. Applies to both consoles."
>
<SegmentedControl
value={mode}
onChange={(v) => setMode(v as ThemeMode)}
label="Theme"
>
<SegmentedControlItem value="light" label="Light" />
<SegmentedControlItem value="dark" label="Dark" />
<SegmentedControlItem value="system" label="System" />
</SegmentedControl>
</StaticPanel>
<ActiveSessionsPanel />
<StaticPanel title="Password and two-factor">
<PendingIntegration
icon="security"
title="Password change and two-factor are not available yet"
description="The platform has no endpoint to change your own password or enrol two-factor. Until it does, a platform admin password can only be changed on the platform itself."
/>
</StaticPanel>
</VStack>
);
}

View File

@@ -1,82 +1,79 @@
'use client';
import {useRouter} from 'next/navigation';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {EmptyState} from '@astryxdesign/core/EmptyState';
import {Button} from '@astryxdesign/core/Button';
import {Badge} from '@astryxdesign/core/Badge';
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
import {BrandMark} from '@/shared/components/brand/BrandLogo';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {AdminShell} from './shell/AdminShell';
/**
* The platform console's frame.
*
* ── Same guard, different shell ──────────────────────────────────────────
* `AuthGuard` is the one the workspace uses, reading the session minted by the
* same login route. What is deliberately absent is `WorkspaceShell`: no site
* switcher, no tenant navigation, no Loyaly AI rail. Every one of those is
* scoped to a company, and a platform operator has none — a store picker with
* nothing in it is worse than no store picker.
*
* ── Why it says "Platform" so loudly ─────────────────────────────────────
* This console can suspend a company and delete one irreversibly. Somebody who
* has both consoles open needs to know which tab they are typing into before
* they click, not after. The badge is the cheapest possible version of that.
* same login route. The shell is NOT WorkspaceShell: that one carries a store
* switcher, tenant navigation and a tenant account menu, every one of which is
* scoped to a company a platform operator does not have. AdminShell keeps the
* shape — rail, top bar, content, Loyaly AI — and drops the tenancy.
*/
export function AdminLayout({children}: {children: React.ReactNode}) {
return (
<AuthGuard>
<VStack gap={0} width="100%" minHeight="100vh">
<AdminTopBar />
<VStack gap={0} width="100%" paddingInline={8} paddingBlock={8}>
{children}
</VStack>
</VStack>
<OperatorsOnly>
<AdminShell>{children}</AdminShell>
</OperatorsOnly>
</AuthGuard>
);
}
function AdminTopBar() {
const {user, logout} = useSession();
/**
* What a merchant session sees if it reaches the platform console.
*
* The proxy normally redirects a merchant away before this renders, but it
* resolves the session from the tab POINTER cookie, while this tab's client
* session comes from its own tab id (see tabSession.ts). With an operator
* signed in in one tab and a merchant in another, the server can render this
* shell for the operator's pointer while the tab itself holds the merchant.
*
* An automatic redirect to /dashboard was tried and does not work: the proxy,
* reading the same operator pointer, sends /dashboard straight back to /admin,
* and the tab settles on a blank page. So this says what happened and offers
* the two ways out, instead of guessing.
*
* Routing only, like the proxy. Nothing leaks either way: every admin read
* carries this tab's id and the platform answers 404 to a merchant.
*/
function OperatorsOnly({children}: {children: React.ReactNode}) {
const {isAuthenticated, user, logout} = useSession();
const router = useRouter();
if (user?.isPlatformAdmin === true) return <>{children}</>;
if (!isAuthenticated) return null;
async function signOut() {
await logout();
router.replace('/login');
router.replace('/login?next=%2Fadmin');
}
return (
<HStack
gap={4}
width="100%"
vAlign="center"
hAlign="between"
paddingInline={8}
paddingBlock={4}
className="border-b border-border bg-surface"
>
<HStack gap={3} vAlign="center">
<BrandMark size={24} />
<Text size="sm" weight="medium">
Platform
</Text>
<Badge variant="neutral" label="Admin" />
</HStack>
<HStack gap={3} vAlign="center">
{/* The address, not the display name: on a console that can delete a
company, which ACCOUNT is signed in matters more than whose it is. */}
<Text size="xsm" color="secondary">
{user?.email}
</Text>
<Button
size="sm"
variant="secondary"
onClick={() => void signOut()}
label="Sign out"
/>
</HStack>
</HStack>
<VStack width="100%" minHeight="100vh" hAlign="center" vAlign="center" padding={6}>
<EmptyState
title="This is the platform admin console"
description={`This tab is signed in as ${user?.email ?? 'a merchant account'}, which is not a platform admin. Sign out and sign in with a platform admin account, or go back to your dashboard.`}
actions={
<HStack gap={2}>
<Button
size="sm"
variant="secondary"
label="Go to dashboard"
href="/dashboard"
/>
<Button size="sm" label="Sign out" onClick={() => void signOut()} />
</HStack>
}
/>
</VStack>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {List, ListItem} from '@astryxdesign/core/List';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {
useAlerts,
useCamera,
useCompany,
useEvents,
useStore,
} from '@/features/admin/hooks/useMonitoring';
import {useAiContext} from '@/features/admin/hooks/useAiContext';
import type {Company} from '@/features/admin/types/company';
import {AdminPageHeader} from './common/AdminPageHeader';
import {AdminSection} from './common/AdminSection';
import {PendingIntegration} from './common/PendingIntegration';
import {CompanyNotFound} from './CompanyDetail';
import {
AlertTable,
CameraStatus,
EventTable,
adminHref,
} from './MonitoringTables';
/**
* One camera, four levels deep: company / store / camera.
*
* ── No fake "LIVE" ───────────────────────────────────────────────────────
* The live feed panel is always the integration-required state today, even
* once camera details arrive. The tenant console streams a camera through its
* own authenticated route; there is no admin equivalent, and a looping
* placeholder with a red LIVE badge would be the single most misleading thing
* this console could draw.
*/
export function CameraDetail({
companyId,
storeId,
cameraId,
}: {
companyId: string;
storeId: string;
cameraId: string;
}) {
const {resource, company} = useCompany(companyId);
return (
<AsyncBoundary
resource={resource}
loading={<SkeletonRows count={4} />}
empty={<CompanyNotFound />}
>
{() =>
company ? (
<CameraBody company={company} storeId={storeId} cameraId={cameraId} />
) : (
<CompanyNotFound />
)
}
</AsyncBoundary>
);
}
function CameraBody({
company,
storeId,
cameraId,
}: {
company: Company;
storeId: string;
cameraId: string;
}) {
const bp = useBreakpoint();
const store = useStore(company.id, storeId);
const camera = useCamera(company.id, storeId, cameraId);
const events = useEvents(company.id, storeId, cameraId);
const alerts = useAlerts(company.id, storeId, cameraId);
const storeName =
store.isAvailable && store.resource.status === 'success'
? store.resource.data.name
: 'Shop';
const cameraName =
camera.isAvailable && camera.resource.status === 'success'
? camera.resource.data.name
: 'Camera';
useAiContext({
level: 'camera',
label: `${cameraName} · ${storeName} · ${company.name}`,
companyId: company.id,
storeId,
cameraId,
});
const twoUp = bp === 'desktop' || bp === 'ultrawide';
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title={cameraName}
subtitle={`${storeName} · ${company.name}`}
back={{label: storeName, href: adminHref.store(company.id, storeId)}}
crumbs={[
{label: 'Merchants', href: adminHref.merchants},
{label: company.name, href: adminHref.company(company.id)},
{label: storeName, href: adminHref.store(company.id, storeId)},
{label: cameraName},
]}
/>
<Grid columns={twoUp ? 2 : 1} gap={4}>
<AdminSection
title="Camera"
data={camera}
pending={{
icon: 'camera',
title: 'Camera data unavailable',
description:
'Status, location and last-seen time need the platform admin camera-detail API.',
}}
loading={<SkeletonRows count={3} />}
>
{(c) => (
<List>
<ListItem label="Status" endContent={<CameraStatus status={c.status} />} />
<ListItem
label="Location"
endContent={<Text size="sm">{c.zone ?? '—'}</Text>}
/>
<ListItem
label="Last seen"
endContent={
<Text size="sm">
{c.lastSeenAt ? new Date(c.lastSeenAt).toLocaleString() : '—'}
</Text>
}
/>
</List>
)}
</AdminSection>
<Card>
<VStack gap={4}>
<SectionHeader title="Live feed" />
<PendingIntegration
icon="camera"
title="Live feed unavailable"
description="There is no platform admin stream endpoint. No feed is shown rather than a placeholder that looks live."
/>
</VStack>
</Card>
</Grid>
<Grid columns={twoUp ? 2 : 1} gap={4}>
<AdminSection
title="Alerts"
data={alerts}
pending={{
icon: 'notifications',
title: 'No alerts available',
description:
'Alert monitoring is not currently available for Platform Admin.',
}}
loading={<SkeletonRows count={3} />}
empty={
<EmptyPanel
icon="notifications"
title="No alerts"
description="This camera has raised no alerts."
/>
}
>
{(rows) => <AlertTable alerts={rows} />}
</AdminSection>
<AdminSection
title="Events and AI detections"
data={events}
pending={{
icon: 'events',
title: 'No event data available',
description: 'Platform Admin event API integration is required.',
}}
loading={<SkeletonRows count={3} />}
empty={
<EmptyPanel
icon="events"
title="No events"
description="This camera has not reported any events yet."
/>
}
>
{(rows) => <EventTable events={rows} />}
</AdminSection>
</Grid>
</VStack>
);
}

View File

@@ -1,278 +0,0 @@
'use client';
import {useState} from 'react';
import {proportional} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Button} from '@astryxdesign/core/Button';
import {TextInput} from '@astryxdesign/core/TextInput';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {
useCompanies,
useCompanyFilters,
} from '@/features/admin/hooks/useCompanies';
import {CreateCompanyDialog} from './CreateCompanyDialog';
import {SuspendCompanyDialog} from './SuspendCompanyDialog';
import {ResetOwnerPasswordDialog} from './ResetOwnerPasswordDialog';
import {DeleteCompanyDialog} from './DeleteCompanyDialog';
import type {Company, CompanyFilter} from '@/features/admin/types/company';
/**
* Every company on the platform, and the four things an operator can do to one.
*
* ── The columns are exactly what the API returns ─────────────────────────
* Name, slug, shops, accounts, status, created. `GET /api/admin/clients`
* returns those seven fields and nothing else, so there is no owner column, no
* plan, no revenue and no health. A column this list cannot fill would have to
* be faked, and a faked column on the console that can delete a company is the
* worst place in the product to put one.
*
* ── Why suspended rows are not hidden ────────────────────────────────────
* A suspended company is the one an operator is most likely to be looking for
* — it is the one somebody is on the phone about. It stays in the default list
* with its status shown, and the filter is there to narrow deliberately.
*/
interface CompanyRow extends Record<string, unknown> {
id: string;
name: string;
slug: string;
sites: number;
users: number;
status: string;
created: string;
}
function toRow(c: Company): CompanyRow {
return {
id: c.id,
name: c.name,
slug: c.slug,
sites: c.sites,
users: c.users,
status: c.isActive ? 'Active' : 'Suspended',
// Date only. A platform operator cares which week a tenant was onboarded,
// never which minute.
created: new Date(c.createdAt).toLocaleDateString(),
};
}
const COLUMNS: TableColumn<CompanyRow>[] = [
{
key: 'name',
header: 'Company',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
{/* The slug is shown on every row because it is what the delete
confirmation asks for, and hunting for it afterwards is how
somebody ends up copying the wrong one. */}
<Text size="xsm" color="secondary">
{row.slug}
</Text>
</VStack>
),
},
{key: 'sites', header: 'Shops', width: proportional(1), align: 'end'},
{key: 'users', header: 'Accounts', width: proportional(1), align: 'end'},
{
key: 'status',
header: 'Status',
width: proportional(1),
renderCell: (row) => (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={row.status === 'Active' ? 'success' : 'error'}
label={row.status}
/>
<Text size="sm" color="secondary">
{row.status}
</Text>
</HStack>
),
},
{key: 'created', header: 'Created', width: proportional(1), align: 'end'},
];
type Action =
| {kind: 'create'}
| {kind: 'suspend'; company: Company}
| {kind: 'reset'; company: Company}
| {kind: 'delete'; company: Company}
| null;
export function CompaniesPanel() {
const companies = useCompanies();
const {query, setQuery, filter, setFilter, visible} = useCompanyFilters(
companies.data,
);
const [action, setAction] = useState<Action>(null);
/**
* Nothing optimistic. Every mutation refetches and the table redraws from
* what the server says — a row that claimed to be suspended because a PATCH
* was sent, while the platform refused it, is exactly the lie this console
* cannot afford.
*/
function refresh() {
companies.refetch();
}
return (
<VStack gap={6} width="100%">
<PanelCard
title="Companies"
subtitle="Every merchant company on this platform."
resource={companies}
loading={<SkeletonRows count={5} />}
empty={
<EmptyPanel
icon="stores"
title="No companies yet"
description="Create your first merchant company to get started."
actions={
<Button
size="sm"
onClick={() => setAction({kind: 'create'})}
label="Create company"
/>
}
/>
}
actions={
<Button
size="sm"
onClick={() => setAction({kind: 'create'})}
label="Create company"
/>
}
>
{(all) => (
<VStack gap={4} width="100%">
<HStack gap={3} vAlign="center" hAlign="between">
<TextInput
label="Search companies"
isLabelHidden
value={query}
onChange={setQuery}
placeholder="Search by name or slug…"
/>
<SegmentedControl
value={filter}
onChange={(v) => setFilter(v as CompanyFilter)}
label="Filter by status"
>
<SegmentedControlItem value="all" label="All" />
<SegmentedControlItem value="active" label="Active" />
<SegmentedControlItem value="suspended" label="Suspended" />
</SegmentedControl>
</HStack>
{visible.length === 0 ? (
/* A search that matches nothing is NOT the same as a platform
with no companies — saying "no companies yet" here would tell
an operator their tenants had vanished. */
<Text size="sm" color="secondary">
No companies match that search. {all.length}{' '}
{all.length === 1 ? 'company' : 'companies'} in total.
</Text>
) : (
<ResponsiveTable
data={visible.map(toRow)}
idKey="id"
primaryKey="name"
summaryKeys={['status', 'sites']}
columns={COLUMNS}
/>
)}
<VStack gap={2} width="100%">
{visible.map((c) => (
<HStack
key={c.id}
gap={2}
vAlign="center"
hAlign="between"
width="100%"
>
<Text size="xsm" color="secondary">
{c.name}
</Text>
<HStack gap={2}>
<Button
size="sm"
variant="secondary"
onClick={() => setAction({kind: 'reset', company: c})}
label="Reset owner password"
/>
<Button
size="sm"
variant="secondary"
onClick={() => setAction({kind: 'suspend', company: c})}
label={c.isActive ? 'Suspend' : 'Reinstate'}
/>
{/* Delete is offered only on a suspended company, because
the platform refuses it otherwise. The dialog still
sends an active one and shows the real 409 if this is
ever reached another way — the guard is a courtesy,
never the rule. */}
{c.isActive ? null : (
<Button
size="sm"
variant="destructive"
onClick={() => setAction({kind: 'delete', company: c})}
label="Delete"
/>
)}
</HStack>
</HStack>
))}
</VStack>
</VStack>
)}
</PanelCard>
{action?.kind === 'create' ? (
<CreateCompanyDialog
onClose={() => setAction(null)}
onCreated={refresh}
/>
) : null}
{action?.kind === 'suspend' ? (
<SuspendCompanyDialog
company={action.company}
onClose={() => setAction(null)}
onDone={refresh}
/>
) : null}
{action?.kind === 'reset' ? (
<ResetOwnerPasswordDialog
company={action.company}
onClose={() => setAction(null)}
/>
) : null}
{action?.kind === 'delete' ? (
<DeleteCompanyDialog
company={action.company}
onClose={() => setAction(null)}
onDeleted={refresh}
/>
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import {useState} from 'react';
import {useRouter} from 'next/navigation';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {SuspendCompanyDialog} from './SuspendCompanyDialog';
import {ResetOwnerPasswordDialog} from './ResetOwnerPasswordDialog';
import {DeleteCompanyDialog} from './DeleteCompanyDialog';
import type {Company} from '@/features/admin/types/company';
export type CompanyDialog = 'suspend' | 'reset' | 'delete';
/**
* The things an operator can do to an existing merchant, in one menu.
*
* Shared by the merchant cards and the merchant page so the two can never
* offer different actions for the same merchant.
*
* There is no "Edit": `PATCH /api/admin/clients/{id}` accepts `active` and
* nothing else, so a rename would be a form that saves nowhere
* (ADMIN_CAPABILITIES.merchantEdit).
*
* Delete is offered only on a suspended merchant, because the platform refuses
* it otherwise. The dialog still sends the request and shows the real 409 if
* it is ever reached another way — the guard here is a courtesy, never the
* rule.
*
* The menu and its dialogs are split so a card can hold the menu while the
* dialogs render outside it — a dialog nested in a clickable card would route
* its clicks through the card.
*/
export function CompanyActionsMenu({
company,
onSelect,
viewHref,
isIconOnly = false,
}: {
company: Company;
onSelect: (dialog: CompanyDialog) => void;
/** Adds a "View details" row — for surfaces that are not the detail page. */
viewHref?: string;
isIconOnly?: boolean;
}) {
const router = useRouter();
return (
<DropdownMenu
button={
isIconOnly
? {
variant: 'ghost',
size: 'sm',
label: `Actions for ${company.name}`,
isIconOnly: true,
icon: <Icon icon="moreHorizontal" size="sm" />,
}
: {
variant: 'secondary',
size: 'sm',
label: 'Manage',
icon: <Icon icon="moreHorizontal" size="sm" />,
}
}
hasChevron={!isIconOnly}
menuWidth={220}
items={[
...(viewHref
? [
{label: 'View details', onClick: () => router.push(viewHref)},
{type: 'divider' as const},
]
: []),
{label: 'Reset owner password', onClick: () => onSelect('reset')},
{
label: company.isActive ? 'Deactivate merchant' : 'Reinstate merchant',
onClick: () => onSelect('suspend'),
},
...(company.isActive
? []
: [
{type: 'divider' as const},
{label: 'Remove merchant', onClick: () => onSelect('delete')},
]),
]}
/>
);
}
export function CompanyActionDialog({
company,
dialog,
onClose,
onChanged,
onDeleted,
}: {
company: Company;
dialog: CompanyDialog;
onClose: () => void;
/** After a suspend/reinstate — refetch, never patch optimistically. */
onChanged: () => void;
onDeleted: () => void;
}) {
if (dialog === 'suspend') {
return (
<SuspendCompanyDialog company={company} onClose={onClose} onDone={onChanged} />
);
}
if (dialog === 'reset') {
return <ResetOwnerPasswordDialog company={company} onClose={onClose} />;
}
return (
<DeleteCompanyDialog company={company} onClose={onClose} onDeleted={onDeleted} />
);
}
/** Menu and dialogs together, for a surface with no clickable container. */
export function CompanyActions({
company,
onChanged,
onDeleted,
isIconOnly = false,
}: {
company: Company;
onChanged: () => void;
onDeleted: () => void;
isIconOnly?: boolean;
}) {
const [dialog, setDialog] = useState<CompanyDialog | null>(null);
return (
<>
<CompanyActionsMenu
company={company}
onSelect={setDialog}
isIconOnly={isIconOnly}
/>
{dialog ? (
<CompanyActionDialog
company={company}
dialog={dialog}
onClose={() => setDialog(null)}
onChanged={onChanged}
onDeleted={onDeleted}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,402 @@
'use client';
import {useState} from 'react';
import {useRouter} from 'next/navigation';
import {Card} from '@astryxdesign/core/Card';
import {Grid} from '@astryxdesign/core/Grid';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Token} from '@astryxdesign/core/Token';
import {Badge} from '@astryxdesign/core/Badge';
import {Icon} from '@astryxdesign/core/Icon';
import {TabList, Tab} from '@astryxdesign/core/TabList';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import {ICONS} from '@/shared/utils/icons';
import type {IconKey} from '@/shared/utils/icons';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {
SkeletonMetricGrid,
SkeletonRows,
} from '@/shared/components/patterns/LoadingState';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {
useCompany,
useCompanyStores,
} from '@/features/admin/hooks/useMonitoring';
import {useAiContext} from '@/features/admin/hooks/useAiContext';
import type {Company} from '@/features/admin/types/company';
import {AdminPageHeader, BackButton} from './common/AdminPageHeader';
import {AdminSection} from './common/AdminSection';
import {CompanyActions} from './CompanyActions';
import {StoreTable, adminHref} from './MonitoringTables';
import {
ADMIN_CAPABILITIES,
MERCHANT_AREAS,
} from '@/features/admin/config/capabilities';
import type {MerchantArea} from '@/features/admin/config/capabilities';
/**
* One merchant: what the platform knows about it, and the way down to its
* shops.
*
* ── Only the fields the platform returns ─────────────────────────────────
* Name, slug, status, shop count, account count, created. There is no owner
* name, plan or contact on the admin surface, so there is no owner field
* here. The shop and account COUNTS are real — they come on the merchant row
* — even though the shops themselves cannot be listed yet, and the Shops tab
* says exactly that rather than rendering an empty table that would read as
* "this merchant has no shops".
*
* ── Tabs only for what can be answered ───────────────────────────────────
* General and Shops. Sales persons, cameras, customers, sales, analytics,
* footfall and device logs are listed on General with their backend state
* (MERCHANT_AREAS) instead of as tabs that could only say "unavailable".
*/
export function CompanyDetail({companyId}: {companyId: string}) {
const router = useRouter();
const {resource, company} = useCompany(companyId);
useAiContext(
company
? {level: 'company', label: company.name, companyId: company.id}
: null,
);
return (
<AsyncBoundary
resource={resource}
loading={
<VStack gap={6} width="100%">
<SkeletonRows count={2} />
<SkeletonMetricGrid columns={4} />
</VStack>
}
empty={<CompanyNotFound />}
>
{() =>
company ? (
<CompanyBody
company={company}
onChanged={resource.refetch}
onDeleted={() => router.replace(adminHref.merchants)}
/>
) : (
<CompanyNotFound />
)
}
</AsyncBoundary>
);
}
export function CompanyNotFound() {
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title="Merchant not found"
crumbs={[
{label: 'Merchants', href: adminHref.merchants},
{label: 'Not found'},
]}
/>
<Card>
<EmptyPanel
icon="companies"
title="No merchant with this id"
description="It may have been deleted, or the link is wrong. The merchant list shows every merchant on the platform."
actions={
<Button
size="sm"
variant="secondary"
href={adminHref.merchants}
label="Back to merchants"
/>
}
/>
</Card>
</VStack>
);
}
function CompanyBody({
company,
onChanged,
onDeleted,
}: {
company: Company;
onChanged: () => void;
onDeleted: () => void;
}) {
const [tab, setTab] = useState<MerchantTab>('general');
const status = company.isActive ? 'Active' : 'Suspended';
return (
<VStack gap={6} width="100%">
<HStack>
<BackButton label="Merchants" href={adminHref.merchants} />
</HStack>
{/* Identity: who this is, whether they can sign in, and what can be
done to them — in one place, above everything that describes them. */}
<Card padding={6}>
<HStack
gap={4}
vAlign="center"
hAlign="between"
width="100%"
className="flex-wrap gap-y-4"
>
<HStack gap={4} vAlign="center" className="min-w-0">
<HStack
hAlign="center"
vAlign="center"
className="size-14 rounded-xl bg-muted shrink-0"
>
<Icon icon={ICONS.companies} size="lg" color="secondary" />
</HStack>
<VStack gap={1} className="min-w-0">
<Text type="display-3" weight="medium" className="break-words">
{company.name}
</Text>
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-1">
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={company.isActive ? 'success' : 'error'}
label={status}
/>
<Text size="sm" color="secondary">
{status}
</Text>
</HStack>
<Text size="sm" color="secondary">
@{company.slug}
</Text>
<Text size="sm" color="secondary">
Created {fmtDate(company.createdAt)}
</Text>
</HStack>
</VStack>
</HStack>
<CompanyActions
company={company}
onChanged={onChanged}
onDeleted={onDeleted}
/>
</HStack>
</Card>
<TabList
value={tab}
onChange={(v) => setTab(v as MerchantTab)}
hasDivider
>
<Tab value="general" label="General" />
<Tab
value="shops"
label="Shops"
endContent={<Badge label={company.sites.toLocaleString()} />}
/>
</TabList>
{tab === 'general' ? (
<MerchantGeneral company={company} />
) : (
<MerchantShops company={company} />
)}
</VStack>
);
}
type MerchantTab = 'general' | 'shops';
const fmtDate = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric',
});
const DAY_MS = 24 * 60 * 60 * 1000;
function MerchantGeneral({company}: {company: Company}) {
const bp = useBreakpoint();
// Read once per mount: rendering must stay pure, and a day count does not
// need to tick while the page is open.
const [now] = useState(() => Date.now());
// Shops has its own tab; everything else is listed with its state.
const areas = MERCHANT_AREAS.filter((a) => a.key !== 'shops');
const live = areas.filter((a) => ADMIN_CAPABILITIES[a.capability]).length;
// Derived from created_at, which the row carries — not an estimate.
const days = Math.max(
0,
Math.floor((now - new Date(company.createdAt).getTime()) / DAY_MS),
);
return (
<VStack gap={6} width="100%">
<Grid columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 4} gap={4}>
<Fact
icon="stores"
label="Shops"
value={company.sites.toLocaleString()}
caption="Registered to this merchant"
/>
<Fact
icon="staff"
label="Merchant accounts"
value={company.users.toLocaleString()}
caption="Logins on this merchant"
/>
<Fact
icon="expiry"
label="Created"
value={fmtDate(company.createdAt)}
caption={`${days.toLocaleString()} ${days === 1 ? 'day' : 'days'} on the platform`}
/>
<Fact
icon={company.isActive ? 'present' : 'absent'}
label="Status"
value={company.isActive ? 'Active' : 'Suspended'}
caption={
company.isActive ? 'Can sign in' : 'Sign-in blocked for everyone'
}
/>
</Grid>
<Card padding={6}>
<VStack gap={5} width="100%">
<SectionHeader
title="Merchant data"
subtitle={`What this console can read for ${company.name}. Every area is scoped to this merchant and its shops only.`}
actions={
<Token size="sm" label={`${live} of ${areas.length} live`} />
}
/>
<Grid columns={bp === 'desktop' || bp === 'ultrawide' ? 2 : 1} gap={3}>
{areas.map((a) => (
<AreaTile
key={a.key}
area={a}
isLive={ADMIN_CAPABILITIES[a.capability]}
/>
))}
</Grid>
</VStack>
</Card>
</VStack>
);
}
/**
* One merchant-level area and whether the platform can answer it. A tile, not
* a list row with a repeated badge: nine identical "Backend integration
* required" tokens read as noise, so the state is a quiet dot + label and the
* summary token above carries the count.
*/
function AreaTile({area, isLive}: {area: MerchantArea; isLive: boolean}) {
return (
<HStack
gap={3}
vAlign="center"
width="100%"
className="rounded-lg bg-muted p-4 min-w-0"
>
<HStack
hAlign="center"
vAlign="center"
className="size-9 rounded-md bg-surface shrink-0"
>
<Icon icon={ICONS[area.icon]} size="sm" color="secondary" />
</HStack>
<VStack gap={0} className="min-w-0 flex-1">
<Text size="sm" weight="medium">
{area.label}
</Text>
<Text size="xsm" color="secondary">
{area.description}
</Text>
</VStack>
<HStack gap={1.5} vAlign="center" className="shrink-0">
<StatusDot
variant={isLive ? 'success' : 'neutral'}
label={isLive ? 'Live' : 'Backend integration required'}
/>
<Text size="xsm" color="secondary">
{isLive ? 'Live' : 'Needs API'}
</Text>
</HStack>
</HStack>
);
}
function MerchantShops({company}: {company: Company}) {
const stores = useCompanyStores(company.id);
const n = company.sites;
const shops = `${n} ${n === 1 ? 'shop' : 'shops'}`;
return (
<AdminSection
title="Shops"
subtitle={`${shops} registered to ${company.name}.`}
data={stores}
pending={{
icon: 'stores',
title: 'Shops cannot be listed yet',
description:
n === 0
? `${company.name} has not opened a shop. Once it does, its shops will be listed here when the platform admin shops API is connected.`
: `The platform reports ${shops} for ${company.name}, but has no platform admin endpoint to list them. Connect the merchant-shops API to open each shop and its cameras from here.`,
}}
loading={<SkeletonRows count={4} />}
empty={
<EmptyPanel
icon="stores"
title="No shops"
description={`${company.name} has not opened a shop yet.`}
/>
}
>
{(rows) => <StoreTable companyId={company.id} stores={rows} />}
</AdminSection>
);
}
/** A labelled fact with its icon. Not a KPI card: these do not trend. */
function Fact({
icon,
label,
value,
caption,
}: {
icon: IconKey;
label: string;
value: string;
caption: string;
}) {
return (
<Card padding={4} className="h-full">
<VStack gap={3} width="100%">
<HStack hAlign="between" vAlign="center" width="100%">
<Text size="sm" color="secondary" weight="medium">
{label}
</Text>
<HStack
hAlign="center"
vAlign="center"
className="size-7 rounded-md bg-muted shrink-0"
>
<Icon icon={ICONS[icon]} size="sm" color="secondary" />
</HStack>
</HStack>
<Text size="xl" weight="semibold" className="tracking-tight">
{value}
</Text>
<Text size="xsm" color="secondary">
{caption}
</Text>
</VStack>
</Card>
);
}

View File

@@ -71,11 +71,11 @@ export function CreateCompanyDialog({
// The platform's own wording. It names the rule that was hit — a
// duplicate address, a slug already taken — and "Something went wrong"
// would throw that away.
setError(res.message ?? 'Could not create that company.');
setError(res.message ?? 'Could not create that merchant.');
return;
}
setCreated({email: res.data.ownerEmail, password: res.data.password});
setCreated({email: res.data.owner_email, password: res.data.password});
// The company exists now, whether or not they close this dialog politely.
onCreated();
}
@@ -99,11 +99,11 @@ export function CreateCompanyDialog({
onOpenChange={(open) => (open ? undefined : close())}
purpose="info"
width={460}
aria-label="Create company"
aria-label="Create merchant"
>
<VStack gap={4} width="100%">
<DialogHeader
title={created ? 'Company created' : 'Create company'}
title={created ? 'Merchant created' : 'Create merchant'}
onOpenChange={(open) => (open ? undefined : close())}
/>
@@ -128,24 +128,24 @@ export function CreateCompanyDialog({
{error ? <Banner status="error" title={error} /> : null}
<TextInput
label="Company name"
label="Merchant name"
value={companyName}
onChange={setCompanyName}
placeholder="TeNext Retail"
placeholder="Business name"
/>
<TextInput
label="Slug (optional)"
value={slug}
onChange={setSlug}
placeholder="Derived from the name"
description="Permanent once set — it becomes part of the company's broker topic."
description="Permanent once set — it becomes part of the merchant's broker topic."
/>
<TextInput
label="Owner email"
type="email"
value={ownerEmail}
onChange={setOwnerEmail}
placeholder="owner@company.com"
placeholder="owner@merchant.com"
/>
<TextInput
label="Owner name"
@@ -165,7 +165,7 @@ export function CreateCompanyDialog({
onClick={() => void submit()}
isDisabled={!canSubmit}
isLoading={busy}
label="Create company"
label="Create merchant"
/>
</HStack>
</VStack>

View File

@@ -59,7 +59,7 @@ export function DeleteCompanyDialog({
if (!res.ok) {
// `still_active` and `storage_error` both name a condition the operator
// can act on — suspend it first, or retry once storage is healthy.
setError(res.message ?? 'Could not delete that company.');
setError(res.message ?? 'Could not delete that merchant.');
return;
}
@@ -73,11 +73,11 @@ export function DeleteCompanyDialog({
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="required"
width={460}
aria-label="Delete company"
aria-label="Delete merchant"
>
<VStack gap={4} width="100%">
<DialogHeader
title="Delete company?"
title="Delete merchant?"
onOpenChange={(open) => (open ? undefined : onClose())}
/>
@@ -97,12 +97,12 @@ export function DeleteCompanyDialog({
<Banner
status="warning"
title="Still active"
description="The platform will refuse this until the company is suspended. Suspend it first, then come back."
description="The platform will refuse this until the merchant is suspended. Suspend it first, then come back."
/>
) : null}
<TextInput
label="Type the company slug to confirm"
label="Type the merchant slug to confirm"
value={confirm}
onChange={setConfirm}
placeholder={company.slug}
@@ -116,7 +116,7 @@ export function DeleteCompanyDialog({
onClick={() => void submit()}
isDisabled={!matches || busy}
isLoading={busy}
label="Delete company"
label="Delete merchant"
/>
</HStack>
</VStack>

View File

@@ -0,0 +1,261 @@
'use client';
import {useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Button} from '@astryxdesign/core/Button';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Selector} from '@astryxdesign/core/Selector';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ICONS} from '@/shared/utils/icons';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import type {Breakpoint} from '@/shared/hooks/useBreakpoint';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {
useCompanies,
useCompanyFilters,
} from '@/features/admin/hooks/useCompanies';
import type {
Company,
CompanyFilter,
CompanySort,
} from '@/features/admin/types/company';
import {CreateCompanyDialog} from './CreateCompanyDialog';
import {
CompanyActionDialog,
CompanyActionsMenu,
type CompanyDialog,
} from './CompanyActions';
import {adminHref} from './MonitoringTables';
/**
* Every merchant on the platform, one card each.
*
* ── A card shows exactly what the API returns ────────────────────────────
* Name, slug, status, shop count, account count, created — the whole
* `GET /api/admin/clients` row. No owner, plan, revenue or health: a field the
* row cannot fill would have to be faked.
*
* ── The card is the way in ───────────────────────────────────────────────
* The whole card opens /admin/merchants/{id}. Its menu carries the row
* actions; the dialogs those open are rendered at panel level, outside the
* card, so a click inside a dialog can never also navigate.
*
* ── Suspended merchants stay in the default list ─────────────────────────
* A suspended merchant is the one somebody is on the phone about. The filter
* is there to narrow deliberately.
*/
const SORTS: {value: CompanySort; label: string}[] = [
{value: 'newest', label: 'Newest first'},
{value: 'oldest', label: 'Oldest first'},
{value: 'name', label: 'Name A–Z'},
{value: 'shops', label: 'Most shops'},
];
function cardColumns(bp: Breakpoint, isPanelOpen: boolean): number {
if (bp === 'mobile') return 1;
if (bp === 'tablet') return 2;
if (bp === 'laptop') return isPanelOpen ? 2 : 3;
if (bp === 'desktop') return 3;
return isPanelOpen ? 3 : 4;
}
const plural = (n: number, one: string, many: string) =>
`${n.toLocaleString()} ${n === 1 ? one : many}`;
export function MerchantsPanel() {
const companies = useCompanies();
const {query, setQuery, filter, setFilter, sort, setSort, visible} =
useCompanyFilters(companies.data);
const [isCreating, setCreating] = useState(false);
const [action, setAction] = useState<{
company: Company;
dialog: CompanyDialog;
} | null>(null);
const bp = useBreakpoint();
const {isOpen} = useLoyalyAi();
const columns = cardColumns(bp, isOpen);
/**
* Nothing optimistic. Every mutation refetches and the cards redraw from
* what the server says.
*/
function refresh() {
companies.refetch();
}
const create = (
<Button size="sm" onClick={() => setCreating(true)} label="Create merchant" />
);
return (
<VStack gap={4} width="100%">
<HStack
gap={3}
vAlign="center"
hAlign="between"
width="100%"
className="flex-wrap gap-y-2"
>
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-2">
<TextInput
label="Search merchants"
isLabelHidden
value={query}
onChange={setQuery}
placeholder="Search by name or slug…"
/>
<SegmentedControl
value={filter}
onChange={(v) => setFilter(v as CompanyFilter)}
label="Filter by status"
>
<SegmentedControlItem value="all" label="All" />
<SegmentedControlItem value="active" label="Active" />
<SegmentedControlItem value="suspended" label="Suspended" />
</SegmentedControl>
</HStack>
<HStack gap={2} vAlign="center">
<Selector
label="Sort merchants"
isLabelHidden
size="sm"
options={SORTS}
value={sort}
onChange={(v) => setSort(v as CompanySort)}
/>
{create}
</HStack>
</HStack>
<AsyncBoundary
resource={companies}
loading={<SkeletonMetricGrid columns={columns} count={columns * 2} />}
empty={
<EmptyPanel
icon="companies"
title="No merchants yet"
description="Create your first merchant to get started."
actions={create}
/>
}
>
{(all) =>
visible.length === 0 ? (
/* A search that matches nothing is NOT a platform with no
merchants — saying "no merchants yet" here would tell an
operator their tenants had vanished. */
<Text size="sm" color="secondary">
No merchants match these filters.{' '}
{plural(all.length, 'merchant', 'merchants')} in total.
</Text>
) : (
<Grid columns={columns} gap={4}>
{visible.map((c) => (
<MerchantCard
key={c.id}
company={c}
onAction={(dialog) => setAction({company: c, dialog})}
/>
))}
</Grid>
)
}
</AsyncBoundary>
{isCreating ? (
<CreateCompanyDialog
onClose={() => setCreating(false)}
onCreated={refresh}
/>
) : null}
{action ? (
<CompanyActionDialog
company={action.company}
dialog={action.dialog}
onClose={() => setAction(null)}
onChanged={refresh}
onDeleted={refresh}
/>
) : null}
</VStack>
);
}
function MerchantCard({
company,
onAction,
}: {
company: Company;
onAction: (dialog: CompanyDialog) => void;
}) {
const href = adminHref.company(company.id);
const status = company.isActive ? 'Active' : 'Suspended';
return (
<ClickableCard
label={`Open ${company.name}`}
href={href}
padding={4}
className="h-full"
>
<VStack gap={4} width="100%" height="100%" vAlign="between">
<HStack gap={3} vAlign="start" hAlign="between" width="100%">
<HStack gap={3} vAlign="center" className="min-w-0">
<HStack
hAlign="center"
vAlign="center"
className="size-9 rounded-md bg-muted shrink-0"
>
<Icon icon={ICONS.companies} size="sm" color="secondary" />
</HStack>
<VStack gap={0} className="min-w-0">
<Text weight="medium" className="truncate">
{company.name}
</Text>
{/* The slug is what the remove confirmation asks for. */}
<Text size="xsm" color="secondary" className="truncate">
{company.slug}
</Text>
</VStack>
</HStack>
<CompanyActionsMenu
company={company}
onSelect={onAction}
viewHref={href}
isIconOnly
/>
</HStack>
<VStack gap={2} width="100%">
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={company.isActive ? 'success' : 'error'}
label={status}
/>
<Text size="sm" color="secondary">
{status}
</Text>
</HStack>
<Text size="xsm" color="secondary">
{plural(company.sites, 'shop', 'shops')} ·{' '}
{plural(company.users, 'account', 'accounts')} · created{' '}
{new Date(company.createdAt).toLocaleDateString()}
</Text>
</VStack>
</VStack>
</ClickableCard>
);
}

View File

@@ -0,0 +1,299 @@
'use client';
import {proportional} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Link} from '@astryxdesign/core/Link';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import type {
AdminAlert,
AdminCamera,
AdminCameraStatus,
AdminEvent,
AdminSeverity,
AdminStore,
} from '@/features/admin/types/monitoring';
/**
* The loaded branch of every drill-down section.
*
* None of these render today — every capability behind them is off, so their
* sections show PendingIntegration instead. They exist so that turning a level
* on is a flag flip: the rows are already written against the shapes in
* types/monitoring.ts, and a missing field prints a dash, never a guess.
*/
const DASH = '—';
function when(iso?: string): string {
return iso ? new Date(iso).toLocaleString() : DASH;
}
export const adminHref = {
merchants: '/admin/merchants',
company: (companyId: string) =>
`/admin/merchants/${encodeURIComponent(companyId)}`,
store: (companyId: string, storeId: string) =>
`${adminHref.company(companyId)}/shops/${encodeURIComponent(storeId)}`,
camera: (companyId: string, storeId: string, cameraId: string) =>
`${adminHref.store(companyId, storeId)}/cameras/${encodeURIComponent(cameraId)}`,
};
/* ── Stores ──────────────────────────────────────────────────────────── */
type StoreRow = Record<string, unknown> & {
id: string;
name: string;
slug: string;
isOnline?: boolean;
timezone: string;
cameras: string;
online: string;
};
export function StoreTable({
companyId,
stores,
}: {
companyId: string;
stores: AdminStore[];
}) {
const columns: TableColumn<StoreRow>[] = [
{
key: 'name',
header: 'Shop',
width: proportional(3),
renderCell: (row) => (
<Link href={adminHref.store(companyId, row.id)}>
<Text size="sm" weight="medium">
{row.name}
</Text>
</Link>
),
},
{
key: 'isOnline',
header: 'Status',
width: proportional(1),
renderCell: (row) =>
row.isOnline === undefined ? (
<Text size="sm">{DASH}</Text>
) : (
<CameraStatus status={row.isOnline ? 'online' : 'offline'} />
),
},
{key: 'timezone', header: 'Timezone', width: proportional(2)},
{key: 'cameras', header: 'Cameras', width: proportional(1), align: 'end'},
{key: 'online', header: 'Online', width: proportional(1), align: 'end'},
];
const rows: StoreRow[] = stores.map((s) => ({
id: s.id,
name: s.name || s.slug,
slug: s.slug,
isOnline: s.isOnline,
timezone: s.timezone ?? DASH,
cameras: s.cameras === undefined ? DASH : String(s.cameras),
online: s.camerasOnline === undefined ? DASH : String(s.camerasOnline),
}));
return (
<ResponsiveTable
data={rows}
columns={columns}
idKey="id"
primaryKey="name"
density="balanced"
/>
);
}
/* ── Cameras ─────────────────────────────────────────────────────────── */
const CAMERA_STATUS: Record<
AdminCameraStatus,
{label: string; dot: 'success' | 'error' | 'neutral'}
> = {
online: {label: 'Online', dot: 'success'},
offline: {label: 'Offline', dot: 'error'},
unknown: {label: 'Unknown', dot: 'neutral'},
};
export function CameraStatus({status}: {status: AdminCameraStatus}) {
const s = CAMERA_STATUS[status];
return (
<HStack gap={1.5} vAlign="center">
<StatusDot variant={s.dot} label={s.label} />
<Text size="sm" color="secondary">
{s.label}
</Text>
</HStack>
);
}
type CameraRow = Record<string, unknown> & {
id: string;
name: string;
zone: string;
status: AdminCameraStatus;
lastSeen: string;
};
export function CameraTable({
companyId,
storeId,
cameras,
}: {
companyId: string;
storeId: string;
cameras: AdminCamera[];
}) {
const columns: TableColumn<CameraRow>[] = [
{
key: 'name',
header: 'Camera',
width: proportional(3),
renderCell: (row) => (
<Link href={adminHref.camera(companyId, storeId, row.id)}>
<Text size="sm" weight="medium">
{row.name}
</Text>
</Link>
),
},
{key: 'zone', header: 'Location', width: proportional(2)},
{
key: 'status',
header: 'Status',
width: proportional(1),
renderCell: (row) => <CameraStatus status={row.status} />,
},
{key: 'lastSeen', header: 'Last seen', width: proportional(2), align: 'end'},
];
const rows: CameraRow[] = cameras.map((c) => ({
id: c.id,
name: c.name,
zone: c.zone ?? DASH,
status: c.status,
lastSeen: when(c.lastSeenAt),
}));
return (
<ResponsiveTable
data={rows}
columns={columns}
idKey="id"
primaryKey="name"
summaryKeys={['status', 'lastSeen']}
density="balanced"
/>
);
}
/* ── Events and alerts ───────────────────────────────────────────────── */
const SEVERITY: Record<
AdminSeverity,
{label: string; dot: 'error' | 'warning' | 'neutral'}
> = {
critical: {label: 'Critical', dot: 'error'},
warning: {label: 'Warning', dot: 'warning'},
info: {label: 'Info', dot: 'neutral'},
};
function Severity({severity}: {severity?: AdminSeverity}) {
if (!severity) return <Text size="sm">{DASH}</Text>;
const s = SEVERITY[severity];
return (
<HStack gap={1.5} vAlign="center">
<StatusDot variant={s.dot} label={s.label} />
<Text size="sm" color="secondary">
{s.label}
</Text>
</HStack>
);
}
type EventRow = Record<string, unknown> & {
id: string;
at: string;
type: string;
camera: string;
severity?: AdminSeverity;
};
export function EventTable({events}: {events: AdminEvent[]}) {
const columns: TableColumn<EventRow>[] = [
{key: 'at', header: 'Time', width: proportional(2)},
{key: 'type', header: 'Event', width: proportional(2)},
{key: 'camera', header: 'Camera', width: proportional(2)},
{
key: 'severity',
header: 'Severity',
width: proportional(1),
renderCell: (row) => <Severity severity={row.severity} />,
},
];
const rows: EventRow[] = events.map((e) => ({
id: e.id,
at: when(e.at),
type: e.type,
camera: e.cameraName ?? DASH,
severity: e.severity,
}));
return (
<ResponsiveTable
data={rows}
columns={columns}
idKey="id"
primaryKey="type"
density="compact"
/>
);
}
type AlertRow = Record<string, unknown> & {
id: string;
at: string;
title: string;
camera: string;
severity: AdminSeverity;
status: string;
};
const ALERT_STATUS: Record<AdminAlert['status'], string> = {
open: 'Open',
acknowledged: 'Acknowledged',
resolved: 'Resolved',
};
export function AlertTable({alerts}: {alerts: AdminAlert[]}) {
const columns: TableColumn<AlertRow>[] = [
{
key: 'severity',
header: 'Severity',
width: proportional(1),
renderCell: (row) => <Severity severity={row.severity} />,
},
{key: 'title', header: 'Alert', width: proportional(3)},
{key: 'camera', header: 'Camera', width: proportional(2)},
{key: 'at', header: 'Time', width: proportional(2)},
{key: 'status', header: 'Status', width: proportional(1)},
];
const rows: AlertRow[] = alerts.map((a) => ({
id: a.id,
at: when(a.at),
title: a.title,
camera: a.cameraName ?? DASH,
severity: a.severity,
status: ALERT_STATUS[a.status],
}));
return (
<ResponsiveTable
data={rows}
columns={columns}
idKey="id"
primaryKey="title"
density="compact"
/>
);
}

View File

@@ -0,0 +1,821 @@
'use client';
import {useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Link} from '@astryxdesign/core/Link';
import {Button} from '@astryxdesign/core/Button';
import {Card} from '@astryxdesign/core/Card';
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Icon} from '@astryxdesign/core/Icon';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Token} from '@astryxdesign/core/Token';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {HoverLift} from '@/shared/components/motion/HoverLift';
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
import {
SkeletonMetricGrid,
SkeletonRows,
} from '@/shared/components/patterns/LoadingState';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {ICONS} from '@/shared/utils/icons';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {useCompanies} from '@/features/admin/hooks/useCompanies';
import {summarise} from '@/features/admin/hooks/useMonitoring';
import {ADMIN_CAPABILITIES} from '@/features/admin/config/capabilities';
import type {AdminCapability} from '@/features/admin/config/capabilities';
import {AdminPageHeader} from './common/AdminPageHeader';
import {CreateCompanyDialog} from './CreateCompanyDialog';
import {adminHref} from './MonitoringTables';
/**
* The landing page for a platform operator: Admin → Merchant → Shop.
*
* ── Every number here is a sum over `GET /api/admin/clients` ─────────────
* Merchants, active, shops and accounts are the list's own `active`, `sites`
* and `users` fields, added up. Nothing is estimated, and no metric the
* platform cannot report (cameras, alerts, events) is shown at all.
*
* The two lists below the KPIs are also derived from the same rows: active
* merchants with no shop (onboarded but never set up — the likeliest support
* call) and the newest merchants.
*/
// Rounded: AnimatedNumber springs between values and hands this every
// in-between frame — unrounded, 6 → 4 shops paints as "3.108".
const fmt = (v: number) => Math.round(v).toLocaleString();
const RECENT_COUNT = 5;
function formatDate(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
} catch {
return iso;
}
}
export function PlatformOverview() {
const companies = useCompanies();
const bp = useBreakpoint();
const [isCreating, setCreating] = useState(false);
// Responsive column budget: 1 on mobile, 2 on tablet, 4 on desktop
const columns = bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 4;
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title="Platform overview"
subtitle="Merchants, their shops and accounts across the Loyaly platform."
meta={
<HStack gap={2} vAlign="center" className="flex-wrap">
<HStack
gap={1.5}
vAlign="center"
className="px-2.5 py-1 rounded-full bg-muted border border-border"
>
<StatusDot variant="success" label="Platform operational" />
<Text size="xsm" weight="medium">
Platform Operational
</Text>
</HStack>
<Text size="xsm" color="secondary">
·
</Text>
<Text size="xsm" color="secondary">
Central Multi-tenant Console
</Text>
</HStack>
}
actions={
<HStack gap={2} vAlign="center">
<Button
size="sm"
variant="secondary"
label="View directory"
href={adminHref.merchants}
/>
<Button
size="sm"
label="Create merchant"
icon={<Icon icon={ICONS.plus} size="sm" />}
onClick={() => setCreating(true)}
/>
</HStack>
}
/>
<AsyncBoundary
resource={companies}
loading={<SkeletonMetricGrid columns={columns} />}
empty={
<EmptyPanel
icon="companies"
title="No merchants yet"
description="Totals appear here once the first merchant is created."
actions={
<Button
size="sm"
label="Create merchant"
icon={<Icon icon={ICONS.plus} size="sm" />}
onClick={() => setCreating(true)}
/>
}
/>
}
>
{(rows) => {
const s = summarise(rows);
const activePercent =
s.companies > 0 ? Math.round((s.active / s.companies) * 100) : 100;
const avgStores = (s.stores / (s.active || 1)).toFixed(1);
const avgUsers = (s.accounts / (s.companies || 1)).toFixed(1);
return (
<Grid columns={columns} gap={4}>
{/* Card 1: Registered Merchants */}
<HoverLift>
<Card padding={4} elevation="low" className="h-full">
<VStack gap={3} width="100%" height="100%" vAlign="between">
<VStack gap={2} width="100%">
<HStack hAlign="between" vAlign="center" width="100%">
<Text size="sm" color="secondary" weight="medium">
Merchants
</Text>
<HStack
hAlign="center"
vAlign="center"
className="size-8 rounded-lg bg-muted text-primary shrink-0"
>
<Icon icon={ICONS.companies} size="sm" />
</HStack>
</HStack>
<HStack gap={2} vAlign="end">
<Text
size="2xl"
weight="semibold"
className="tracking-tight"
>
<AnimatedNumber value={s.companies} format={fmt} />
</Text>
<Text size="xsm" color="secondary">
tenants
</Text>
</HStack>
</VStack>
<VStack gap={2} width="100%">
<ProgressBar
value={activePercent}
label="Active rate"
isLabelHidden
variant="success"
/>
<HStack hAlign="between" vAlign="center" width="100%">
<HStack gap={1.5} vAlign="center">
<StatusDot variant="success" label="Active" />
<Text size="xsm" color="secondary">
{s.active} active
</Text>
</HStack>
<Text size="xsm" color="secondary">
{s.suspended} suspended
</Text>
</HStack>
</VStack>
</VStack>
</Card>
</HoverLift>
{/* Card 2: Active Merchants */}
<HoverLift>
<Card padding={4} elevation="low" className="h-full">
<VStack gap={3} width="100%" height="100%" vAlign="between">
<VStack gap={2} width="100%">
<HStack hAlign="between" vAlign="center" width="100%">
<Text size="sm" color="secondary" weight="medium">
Active merchants
</Text>
<HStack
hAlign="center"
vAlign="center"
className="size-8 rounded-lg bg-muted text-primary shrink-0"
>
<Icon icon={ICONS.present} size="sm" />
</HStack>
</HStack>
<HStack gap={2} vAlign="end">
<Text
size="2xl"
weight="semibold"
className="tracking-tight"
>
<AnimatedNumber value={s.active} format={fmt} />
</Text>
<Text size="xsm" color="secondary">
active
</Text>
</HStack>
</VStack>
<HStack
hAlign="between"
vAlign="center"
width="100%"
className="pt-2 border-t border-border"
>
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={
s.active === s.companies ? 'success' : 'warning'
}
label="Health"
/>
<Text size="xsm" weight="medium">
{activePercent}% active rate
</Text>
</HStack>
<Text size="xsm" color="secondary">
{s.companies - s.active === 0
? 'All nominal'
: `${s.companies - s.active} inactive`}
</Text>
</HStack>
</VStack>
</Card>
</HoverLift>
{/* Card 3: Shops */}
<HoverLift>
<Card padding={4} elevation="low" className="h-full">
<VStack gap={3} width="100%" height="100%" vAlign="between">
<VStack gap={2} width="100%">
<HStack hAlign="between" vAlign="center" width="100%">
<Text size="sm" color="secondary" weight="medium">
Shops
</Text>
<HStack
hAlign="center"
vAlign="center"
className="size-8 rounded-lg bg-muted text-primary shrink-0"
>
<Icon icon={ICONS.stores} size="sm" />
</HStack>
</HStack>
<HStack gap={2} vAlign="end">
<Text
size="2xl"
weight="semibold"
className="tracking-tight"
>
<AnimatedNumber value={s.stores} format={fmt} />
</Text>
<Text size="xsm" color="secondary">
locations
</Text>
</HStack>
</VStack>
<HStack
hAlign="between"
vAlign="center"
width="100%"
className="pt-2 border-t border-border"
>
<Text size="xsm" color="secondary" className="truncate">
{s.active} active {s.active === 1 ? 'client' : 'clients'}
</Text>
<Token size="sm" label={`${avgStores} avg / tenant`} />
</HStack>
</VStack>
</Card>
</HoverLift>
{/* Card 4: Accounts */}
<HoverLift>
<Card padding={4} elevation="low" className="h-full">
<VStack gap={3} width="100%" height="100%" vAlign="between">
<VStack gap={2} width="100%">
<HStack hAlign="between" vAlign="center" width="100%">
<Text size="sm" color="secondary" weight="medium">
Merchant accounts
</Text>
<HStack
hAlign="center"
vAlign="center"
className="size-8 rounded-lg bg-muted text-primary shrink-0"
>
<Icon icon={ICONS.staff} size="sm" />
</HStack>
</HStack>
<HStack gap={2} vAlign="end">
<Text
size="2xl"
weight="semibold"
className="tracking-tight"
>
<AnimatedNumber value={s.accounts} format={fmt} />
</Text>
<Text size="xsm" color="secondary">
credentials
</Text>
</HStack>
</VStack>
<HStack
hAlign="between"
vAlign="center"
width="100%"
className="pt-2 border-t border-border"
>
<Text size="xsm" color="secondary" className="truncate">
Across {s.companies}{' '}
{s.companies === 1 ? 'merchant' : 'merchants'}
</Text>
<Token size="sm" label={`${avgUsers} users / tenant`} />
</HStack>
</VStack>
</Card>
</HoverLift>
</Grid>
);
}}
</AsyncBoundary>
<Grid columns={bp === 'mobile' || bp === 'tablet' ? 1 : 2} gap={4}>
{/* Left Panel: Merchant Setup Status */}
<Card padding={5} elevation="low">
<VStack gap={4} width="100%">
<HStack hAlign="between" vAlign="start" width="100%">
<VStack gap={0.5}>
<Text size="lg" weight="semibold">
Merchant setup status
</Text>
<Text size="sm" color="secondary">
Active merchants that have not opened a shop yet.
</Text>
</VStack>
</HStack>
<AsyncBoundary
resource={companies}
loading={<SkeletonRows count={3} />}
>
{(rows) => {
const s = summarise(rows);
const pending = s.withoutStores;
if (pending.length === 0) {
return (
<VStack gap={4} width="100%">
<HStack
gap={3}
vAlign="center"
className="p-4 rounded-xl bg-muted/60 border border-border"
>
<HStack
hAlign="center"
vAlign="center"
className="size-10 rounded-full bg-surface text-primary border border-border shrink-0 shadow-sm"
>
<Icon icon={ICONS.roles} size="md" />
</HStack>
<VStack gap={0.5} className="min-w-0">
<HStack gap={2} vAlign="center">
<Text weight="semibold">
All merchants operational
</Text>
<Token size="sm" label="100% Configured" />
</HStack>
<Text size="sm" color="secondary">
Every active merchant has at least one shop
configured and provisioned for commerce.
</Text>
</VStack>
</HStack>
<Grid columns={3} gap={2}>
<Card padding={3} variant="muted">
<VStack gap={0.5}>
<Text size="xsm" color="secondary">
Setup complete
</Text>
<Text size="base" weight="semibold">
{s.active} / {s.companies}
</Text>
<Text size="xsm" color="secondary">
100% active rate
</Text>
</VStack>
</Card>
<Card padding={3} variant="muted">
<VStack gap={0.5}>
<Text size="xsm" color="secondary">
Live storefronts
</Text>
<Text size="base" weight="semibold">
{s.stores}
</Text>
<Text size="xsm" color="secondary">
retail locations
</Text>
</VStack>
</Card>
<Card padding={3} variant="muted">
<VStack gap={0.5}>
<Text size="xsm" color="secondary">
Pending setup
</Text>
<Text size="base" weight="semibold">
0
</Text>
<Text size="xsm" color="secondary">
no backlog
</Text>
</VStack>
</Card>
</Grid>
<HStack hAlign="between" vAlign="center" className="pt-1">
<Text size="xsm" color="secondary">
Unconfigured accounts will be highlighted here.
</Text>
<Button
size="sm"
variant="ghost"
label="+ Add merchant"
onClick={() => setCreating(true)}
/>
</HStack>
</VStack>
);
}
return (
<VStack gap={3} width="100%">
<HStack
gap={2}
vAlign="center"
className="p-3 rounded-lg bg-muted border border-border"
>
<StatusDot variant="warning" label="Pending" />
<Text size="sm" weight="medium">
{pending.length}{' '}
{pending.length === 1
? 'merchant requires'
: 'merchants require'}{' '}
shop setup.
</Text>
</HStack>
<VStack gap={2} width="100%">
{pending.map((c) => (
<ClickableCard
key={c.id}
href={adminHref.company(c.id)}
label={`Set up ${c.name}`}
padding={3}
elevation="none"
className="hover:border-primary transition-all"
>
<HStack
hAlign="between"
vAlign="center"
width="100%"
gap={3}
>
<HStack gap={3} vAlign="center" className="min-w-0">
<Avatar name={c.name} size="sm" tooltip={false} />
<VStack gap={0.5} className="min-w-0">
<HStack gap={2} vAlign="center">
<Text weight="semibold" className="truncate">
{c.name}
</Text>
<Token size="sm" label={`@${c.slug}`} />
</HStack>
<Text size="xsm" color="secondary">
0 shops · {c.users}{' '}
{c.users === 1 ? 'account' : 'accounts'} ·
Added {formatDate(c.createdAt)}
</Text>
</VStack>
</HStack>
<HStack
gap={2}
vAlign="center"
className="shrink-0"
>
<Token size="sm" label="Needs shop" />
<Icon
icon={ICONS.arrowRight}
size="sm"
color="secondary"
/>
</HStack>
</HStack>
</ClickableCard>
))}
</VStack>
</VStack>
);
}}
</AsyncBoundary>
</VStack>
</Card>
{/* Right Panel: Recently Added Merchants */}
<Card padding={5} elevation="low">
<VStack gap={4} width="100%">
<HStack hAlign="between" vAlign="start" width="100%">
<VStack gap={0.5}>
<Text size="lg" weight="semibold">
Recently added merchants
</Text>
<Text size="sm" color="secondary">
The newest merchants on the platform.
</Text>
</VStack>
<Button
size="sm"
variant="ghost"
label="View all"
href={adminHref.merchants}
/>
</HStack>
<AsyncBoundary
resource={companies}
loading={<SkeletonRows count={3} />}
>
{(rows) => {
const recent = summarise(rows).recent.slice(0, RECENT_COUNT);
return (
<VStack gap={2} width="100%">
{recent.map((c) => (
<ClickableCard
key={c.id}
href={adminHref.company(c.id)}
label={`View ${c.name}`}
padding={3}
elevation="none"
className="hover:border-primary transition-all"
>
<HStack
hAlign="between"
vAlign="center"
width="100%"
gap={3}
>
<HStack gap={3} vAlign="center" className="min-w-0">
<Avatar name={c.name} size="sm" tooltip={false} />
<VStack gap={0.5} className="min-w-0">
<HStack gap={2} vAlign="center">
<Text weight="semibold" className="truncate">
{c.name}
</Text>
<Token size="sm" label={`@${c.slug}`} />
</HStack>
<HStack
gap={2}
vAlign="center"
className="flex-wrap"
>
<HStack gap={1} vAlign="center">
<Icon
icon={ICONS.stores}
size="xsm"
color="secondary"
/>
<Text size="xsm" color="secondary">
{c.sites} {c.sites === 1 ? 'shop' : 'shops'}
</Text>
</HStack>
<Text size="xsm" color="secondary">
·
</Text>
<HStack gap={1} vAlign="center">
<Icon
icon={ICONS.staff}
size="xsm"
color="secondary"
/>
<Text size="xsm" color="secondary">
{c.users}{' '}
{c.users === 1 ? 'account' : 'accounts'}
</Text>
</HStack>
<Text size="xsm" color="secondary">
·
</Text>
<Text size="xsm" color="secondary">
Added {formatDate(c.createdAt)}
</Text>
</HStack>
</VStack>
</HStack>
<HStack
gap={2}
vAlign="center"
className="shrink-0"
>
<StatusDot
variant={c.isActive ? 'success' : 'error'}
label={c.isActive ? 'Active' : 'Suspended'}
/>
<Icon
icon={ICONS.arrowRight}
size="sm"
color="secondary"
/>
</HStack>
</HStack>
</ClickableCard>
))}
</VStack>
);
}}
</AsyncBoundary>
</VStack>
</Card>
</Grid>
<MonitoringCoverage />
{isCreating ? (
<CreateCompanyDialog
onClose={() => setCreating(false)}
onCreated={() => companies.refetch()}
/>
) : null}
</VStack>
);
}
/**
* Which rungs of the drill-down the platform can answer today, read straight
* from ADMIN_CAPABILITIES — so this panel cannot drift from what the pages
* actually do.
*/
interface CoverageItem {
label: string;
capability: AdminCapability;
description: string;
icon: typeof ICONS.companies;
href?: string;
}
const COVERAGE: CoverageItem[] = [
{
label: 'Merchants',
capability: 'companies',
description:
'Tenant isolation, company lifecycle, and operator credential provisioning.',
icon: ICONS.companies,
href: adminHref.merchants,
},
{
label: 'Merchant shops',
capability: 'companyStores',
description:
'Storefront network topology, physical address mapping, and device linkages.',
icon: ICONS.stores,
},
{
label: 'Footfall',
capability: 'footfall',
description:
'Walk-in visitor velocity, camera detection streams, and dwell-time analytics.',
icon: ICONS.visitors,
href: '/admin/footfall',
},
{
label: 'Commerce',
capability: 'commerce',
description:
'Point-of-sale transactions, daily revenue tracking, and basket size metrics.',
icon: ICONS.commerce,
href: '/admin/commerce',
},
{
label: 'Platform Loyaly AI',
capability: 'platformAssistant',
description:
'Cross-merchant operational copilot, anomaly diagnosis, and assistant rail.',
icon: ICONS.ai,
},
];
function MonitoringCoverage() {
const bp = useBreakpoint();
const liveCount = COVERAGE.filter(
(c) => ADMIN_CAPABILITIES[c.capability],
).length;
return (
<Card padding={5} elevation="low">
<VStack gap={5} width="100%">
<HStack
hAlign="between"
vAlign="center"
width="100%"
className="flex-wrap gap-y-2"
>
<VStack gap={0.5}>
<Text size="lg" weight="semibold">
Monitoring coverage
</Text>
<Text size="sm" color="secondary">
What this console can read from the platform today.
</Text>
</VStack>
<HStack
gap={1.5}
vAlign="center"
className="px-3 py-1 rounded-full bg-muted border border-border"
>
<StatusDot variant="success" label="Live systems" />
<Text size="xsm" weight="medium">
{liveCount} of {COVERAGE.length} Subsystems Live
</Text>
</HStack>
</HStack>
<Grid columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 3} gap={3}>
{COVERAGE.map(({label, capability, description, icon, href}) => {
const live = ADMIN_CAPABILITIES[capability];
return (
<Card
key={capability}
padding={4}
variant="muted"
className="h-full border border-border"
>
<VStack gap={3} width="100%" height="100%" vAlign="between">
<VStack gap={2} width="100%">
<HStack hAlign="between" vAlign="center" width="100%">
<HStack
hAlign="center"
vAlign="center"
className="size-8 rounded-lg bg-surface text-primary border border-border shrink-0 shadow-sm"
>
<Icon icon={icon} size="sm" />
</HStack>
{live ? (
<HStack
gap={1.5}
vAlign="center"
className="px-2 py-0.5 rounded-full bg-surface border border-border"
>
<StatusDot variant="success" label="Live" />
<Text size="xsm" weight="medium">
Live
</Text>
</HStack>
) : (
<Token
size="sm"
label="Backend integration required"
/>
)}
</HStack>
<VStack gap={0.5} width="100%">
<Text weight="semibold">{label}</Text>
<Text size="xsm" color="secondary">
{description}
</Text>
</VStack>
</VStack>
{href && live ? (
<HStack
width="100%"
className="pt-2 border-t border-border"
>
<Link href={href} className="text-xs">
Open {label.toLowerCase()} →
</Link>
</HStack>
) : null}
</VStack>
</Card>
);
})}
</Grid>
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,336 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Selector} from '@astryxdesign/core/Selector';
import type {SelectorOptionType} from '@astryxdesign/core/Selector';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {MetricCard} from '@/shared/components/patterns/MetricCard';
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {ICONS} from '@/shared/utils/icons';
import {useCompanies} from '@/features/admin/hooks/useCompanies';
import {useAdminData} from '@/features/admin/hooks/useMonitoring';
import {useAiContext} from '@/features/admin/hooks/useAiContext';
import {storeRepository} from '@/features/admin/repositories/monitoringRepository';
import type {Company} from '@/features/admin/types/company';
import type {AnalyticsFilters, DateRange} from '@/features/admin/types/analytics';
import {AdminPageHeader} from './common/AdminPageHeader';
import {FootfallDashboard} from './analytics/FootfallDashboard';
import {CommerceDashboard} from './analytics/CommerceDashboard';
/**
* Footfall and Commerce — platform analytics over per-shop data.
*
* ── The hierarchy is the filter bar ──────────────────────────────────────
* Footfall Merchant → Area → Shop → Date range
* Commerce Merchant → Shop → Date range
* Each control narrows the next: changing the merchant clears the area and the
* shop, and a shop is only ever offered as a shop of the selected merchant.
* The filters flow into analyticsRepository as query parameters, so every
* chart refetches under the new scope and none can show another merchant's
* rows (docs/ADMIN-MONITORING-API.md §2.2 Ownership).
*
* ── What is real today ───────────────────────────────────────────────────
* The merchant list and the scope cards (sums over `GET /api/admin/clients`),
* and the date range. Every chart and table is built and wired; each renders
* its "backend integration required" frame until its endpoint ships, then
* fills in with no component change (config/capabilities.ts).
*/
const ALL = 'all';
type Kind = 'footfall' | 'commerce';
const COPY: Record<Kind, {title: string; subtitle: string}> = {
footfall: {
title: 'Footfall',
subtitle: 'Walk-ins by merchant, area and shop, day by day.',
},
commerce: {
title: 'Commerce',
subtitle: 'Sales and transactions by merchant and shop, day by day.',
},
};
type RangeKey = '7d' | '30d' | '90d';
const RANGES: {value: RangeKey; label: string; days: number}[] = [
{value: '7d', label: 'Last 7 days', days: 7},
{value: '30d', label: 'Last 30 days', days: 30},
{value: '90d', label: 'Last 90 days', days: 90},
];
const isoDay = (t: number) => new Date(t).toISOString().slice(0, 10);
function rangeFor(key: RangeKey, now: number): DateRange {
const days = RANGES.find((r) => r.value === key)?.days ?? 30;
return {from: isoDay(now - (days - 1) * 86_400_000), to: isoDay(now)};
}
export function PlatformFootfall() {
useAiContext({level: 'platform', label: 'Footfall'});
return <ScopedArea kind="footfall" />;
}
export function PlatformCommerce() {
useAiContext({level: 'platform', label: 'Commerce'});
return <ScopedArea kind="commerce" />;
}
function ScopedArea({kind}: {kind: Kind}) {
const companies = useCompanies();
const {title, subtitle} = COPY[kind];
return (
<VStack gap={6} width="100%">
<AdminPageHeader title={title} subtitle={subtitle} />
<AsyncBoundary
resource={companies}
loading={<SkeletonMetricGrid columns={3} count={3} />}
empty={
<EmptyPanel
icon="companies"
title="No merchants yet"
description={`${title} belongs to a merchant's shops. Create a merchant first.`}
/>
}
>
{(rows) => <ScopedBody merchants={rows} kind={kind} />}
</AsyncBoundary>
</VStack>
);
}
function ScopedBody({merchants, kind}: {merchants: Company[]; kind: Kind}) {
const [merchantId, setMerchantId] = useState<string>(ALL);
const [area, setArea] = useState<string | undefined>();
const [shopId, setShopId] = useState<string | undefined>();
const [rangeKey, setRangeKey] = useState<RangeKey>('30d');
// "Today" is read once per mount — rendering stays pure, and the range does
// not drift while the page is open.
const [now] = useState(() => Date.now());
// A merchant that vanished from the list (deleted in another tab) falls
// back to the platform scope rather than to a stale name.
const merchant = merchants.find((m) => m.id === merchantId) ?? null;
const filters: AnalyticsFilters = useMemo(
() => ({
merchantId: merchant?.id,
area,
shopId,
range: rangeFor(rangeKey, now),
}),
[merchant?.id, area, shopId, rangeKey, now],
);
return (
<VStack gap={6} width="100%">
<FilterBar
kind={kind}
merchants={merchants}
merchant={merchant}
onMerchantChange={(v) => {
setMerchantId(v || ALL);
// Area and shop belong to the previous merchant — never carried over.
setArea(undefined);
setShopId(undefined);
}}
shopId={shopId}
onShopChange={setShopId}
rangeKey={rangeKey}
onRangeChange={setRangeKey}
/>
<ScopeMetrics merchants={merchants} merchant={merchant} />
{kind === 'footfall' ? (
<FootfallDashboard filters={filters} />
) : (
<CommerceDashboard filters={filters} />
)}
</VStack>
);
}
/**
* The selected merchant's shops, for the Shop filter — from the real
* merchant-shops endpoint when the platform has it, nothing otherwise.
*/
function useShopOptions(merchantId: string | undefined) {
const shops = useAdminData(
merchantId ? storeRepository.listByCompany(merchantId) : null,
);
const options =
shops.isAvailable && shops.resource.status === 'success'
? shops.resource.data.map((s) => ({value: s.id, label: s.name}))
: [];
return {isAvailable: shops.isAvailable, options};
}
const NEEDS_MERCHANT = 'Choose a merchant first.';
function FilterBar({
kind,
merchants,
merchant,
onMerchantChange,
shopId,
onShopChange,
rangeKey,
onRangeChange,
}: {
kind: Kind;
merchants: Company[];
merchant: Company | null;
onMerchantChange: (v: string) => void;
shopId: string | undefined;
onShopChange: (v: string | undefined) => void;
rangeKey: RangeKey;
onRangeChange: (v: RangeKey) => void;
}) {
const shops = useShopOptions(merchant?.id);
const merchantOptions: SelectorOptionType[] = [
{value: ALL, label: 'All merchants'},
{type: 'divider'},
...[...merchants]
.sort((a, b) => a.name.localeCompare(b.name))
.map((m) => ({
value: m.id,
label: m.isActive ? m.name : `${m.name} (suspended)`,
})),
];
const shopDisabledReason = !merchant
? NEEDS_MERCHANT
: !shops.isAvailable
? 'Backend integration required — the platform has no admin endpoint that lists a merchant’s shops.'
: shops.options.length === 0
? 'This merchant has no shops.'
: undefined;
return (
<HStack gap={3} vAlign="end" className="flex-wrap gap-y-2">
<Selector
label="Merchant"
options={merchantOptions}
value={merchant ? merchant.id : ALL}
onChange={onMerchantChange}
hasSearch
width={240}
/>
{kind === 'footfall' ? (
<Selector
label="Area / location"
options={[]}
placeholder="All areas"
isDisabled
disabledMessage={
merchant
? 'Backend integration required — shops carry no area or location field yet.'
: NEEDS_MERCHANT
}
width={200}
/>
) : null}
<Selector
label="Shop"
options={[{value: ALL, label: 'All shops'}, ...shops.options]}
value={shopId ?? ALL}
onChange={(v) => onShopChange(v && v !== ALL ? v : undefined)}
placeholder="All shops"
isDisabled={shopDisabledReason !== undefined}
disabledMessage={shopDisabledReason}
width={200}
/>
<Selector
label="Date range"
options={RANGES.map(({value, label}) => ({value, label}))}
value={rangeKey}
onChange={(v) => onRangeChange(v as RangeKey)}
width={180}
/>
</HStack>
);
}
// Rounded: AnimatedNumber springs between values and hands this every
// in-between frame — unrounded, 6 → 4 shops paints as "3.108".
const fmt = (v: number) => Math.round(v).toLocaleString();
/**
* What the merchant list can say about the current scope — nothing more.
* No footfall, sales or transaction number appears here: none is returned.
*/
function ScopeMetrics({
merchants,
merchant,
}: {
merchants: Company[];
merchant: Company | null;
}) {
const bp = useBreakpoint();
const caption = (t: string) => (
<Text size="xsm" color="secondary">
{t}
</Text>
);
// Keyed per scope: a different merchant is a different entity, not a value
// that moved, so the cards remount and paint the new numbers directly rather
// than springing through counts that belong to neither merchant.
if (merchant) {
return (
<Grid key={merchant.id} columns={bp === 'mobile' ? 1 : 2} gap={4}>
<MetricCard
label="Shops"
value={merchant.sites}
format={fmt}
icon={ICONS.stores}
footer={caption(`Registered to ${merchant.name}`)}
/>
<MetricCard
label="Merchant accounts"
value={merchant.users}
format={fmt}
icon={ICONS.staff}
footer={caption(merchant.isActive ? 'Active merchant' : 'Suspended merchant')}
/>
</Grid>
);
}
const active = merchants.filter((m) => m.isActive);
return (
<Grid key="all" columns={bp === 'mobile' ? 1 : 3} gap={4}>
<MetricCard
label="Active merchants"
value={active.length}
format={fmt}
icon={ICONS.companies}
footer={caption(`${fmt(merchants.length)} on the platform`)}
/>
<MetricCard
label="Shops"
value={active.reduce((n, m) => n + m.sites, 0)}
format={fmt}
icon={ICONS.stores}
footer={caption('Registered to active merchants')}
/>
<MetricCard
label="Merchants without a shop"
value={active.filter((m) => m.sites === 0).length}
format={fmt}
icon={ICONS.stores}
footer={caption('Active, with nothing to report yet')}
/>
</Grid>
);
}

View File

@@ -126,8 +126,8 @@ export function ResetOwnerPasswordDialog({
type="email"
value={email}
onChange={setEmail}
placeholder="owner@company.com"
description="This company has more than one owner — name the one to reset."
placeholder="owner@merchant.com"
description="This merchant has more than one owner — name the one to reset."
/>
) : null}

View File

@@ -0,0 +1,205 @@
'use client';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {List, ListItem} from '@astryxdesign/core/List';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {
useAlerts,
useCompany,
useEvents,
useStore,
useStoreCameras,
} from '@/features/admin/hooks/useMonitoring';
import {useAiContext} from '@/features/admin/hooks/useAiContext';
import type {Company} from '@/features/admin/types/company';
import {AdminPageHeader} from './common/AdminPageHeader';
import {AdminSection} from './common/AdminSection';
import {CompanyNotFound} from './CompanyDetail';
import {
AlertTable,
CameraTable,
EventTable,
adminHref,
} from './MonitoringTables';
/**
* One store of one company.
*
* ── The pair, never the store alone ──────────────────────────────────────
* The route is /admin/merchants/{merchantId}/shops/{shopId}, and every
* request this page makes nests the same way. The company is resolved first
* from the real list — a store under a company that does not exist is a
* not-found page, not a request. Whether the store really belongs to that
* company is the PLATFORM's check (docs/ADMIN-MONITORING-API.md §Ownership);
* the URL is a claim, and this page never treats it as proof.
*
* Today no store-level endpoint exists, so the page is reachable (the route is
* real and the breadcrumbs work) and every section says what is missing.
*/
export function StoreDetail({
companyId,
storeId,
}: {
companyId: string;
storeId: string;
}) {
const {resource, company} = useCompany(companyId);
return (
<AsyncBoundary
resource={resource}
loading={<SkeletonRows count={4} />}
empty={<CompanyNotFound />}
>
{() =>
company ? (
<StoreBody company={company} storeId={storeId} />
) : (
<CompanyNotFound />
)
}
</AsyncBoundary>
);
}
function StoreBody({company, storeId}: {company: Company; storeId: string}) {
const bp = useBreakpoint();
const store = useStore(company.id, storeId);
const cameras = useStoreCameras(company.id, storeId);
const events = useEvents(company.id, storeId);
const alerts = useAlerts(company.id, storeId);
// The store's NAME only exists once the store endpoint answers. Until then
// the page says "Store" rather than printing a raw id as if it were a name.
const name =
store.isAvailable && store.resource.status === 'success'
? store.resource.data.name
: 'Shop';
useAiContext({
level: 'store',
label: `${name} · ${company.name}`,
companyId: company.id,
storeId,
});
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title={name}
subtitle={`A shop of ${company.name}.`}
back={{label: company.name, href: adminHref.company(company.id)}}
crumbs={[
{label: 'Merchants', href: adminHref.merchants},
{label: company.name, href: adminHref.company(company.id)},
{label: name},
]}
/>
<AdminSection
title="Shop overview"
data={store}
pending={{
icon: 'stores',
title: 'Shop details are not available yet',
description:
'Status, location and camera counts for this shop need the platform admin shop-detail API.',
}}
loading={<SkeletonRows count={3} />}
>
{(s) => (
<List>
<ListItem label="Timezone" endContent={<Text size="sm">{s.timezone ?? '—'}</Text>} />
<ListItem
label="Cameras"
endContent={<Text size="sm">{s.cameras ?? '—'}</Text>}
/>
<ListItem
label="Cameras online"
endContent={<Text size="sm">{s.camerasOnline ?? '—'}</Text>}
/>
<ListItem
label="Created"
endContent={
<Text size="sm">
{s.createdAt ? new Date(s.createdAt).toLocaleDateString() : '—'}
</Text>
}
/>
</List>
)}
</AdminSection>
<AdminSection
title="Cameras"
data={cameras}
pending={{
icon: 'camera',
title: 'Camera data unavailable',
description:
'Connect the platform admin store-cameras API to list this shop’s cameras with their status and last-seen time.',
}}
loading={<SkeletonRows count={4} />}
empty={
<EmptyPanel
icon="camera"
title="No cameras"
description="No camera is registered to this shop."
/>
}
>
{(rows) => (
<CameraTable companyId={company.id} storeId={storeId} cameras={rows} />
)}
</AdminSection>
<Grid columns={bp === 'desktop' || bp === 'ultrawide' ? 2 : 1} gap={4}>
<AdminSection
title="Active alerts"
data={alerts}
pending={{
icon: 'notifications',
title: 'No alerts available',
description:
'Alert monitoring is not currently available for Platform Admin.',
}}
loading={<SkeletonRows count={3} />}
empty={
<EmptyPanel
icon="notifications"
title="No open alerts"
description="Nothing needs attention at this shop."
/>
}
>
{(rows) => <AlertTable alerts={rows} />}
</AdminSection>
<AdminSection
title="Recent events"
data={events}
pending={{
icon: 'events',
title: 'No event data available',
description: 'Platform Admin event API integration is required.',
}}
loading={<SkeletonRows count={3} />}
empty={
<EmptyPanel
icon="events"
title="No events"
description="This shop has not reported any events yet."
/>
}
>
{(rows) => <EventTable events={rows} />}
</AdminSection>
</Grid>
</VStack>
);
}

View File

@@ -53,8 +53,8 @@ export function SuspendCompanyDialog({
setError(
res.message ??
(suspending
? 'Could not suspend that company.'
: 'Could not reinstate that company.'),
? 'Could not suspend that merchant.'
: 'Could not reinstate that merchant.'),
);
return;
}
@@ -69,11 +69,11 @@ export function SuspendCompanyDialog({
onOpenChange={(open) => (open ? undefined : onClose())}
purpose={suspending ? 'required' : 'info'}
width={440}
aria-label={suspending ? 'Suspend company' : 'Reinstate company'}
aria-label={suspending ? 'Suspend merchant' : 'Reinstate merchant'}
>
<VStack gap={4} width="100%">
<DialogHeader
title={suspending ? 'Suspend company?' : 'Reinstate company?'}
title={suspending ? 'Suspend merchant?' : 'Reinstate merchant?'}
onOpenChange={(open) => (open ? undefined : onClose())}
/>
@@ -103,7 +103,7 @@ export function SuspendCompanyDialog({
variant={suspending ? 'destructive' : 'primary'}
onClick={() => void submit()}
isLoading={busy}
label={suspending ? 'Suspend company' : 'Reinstate company'}
label={suspending ? 'Suspend merchant' : 'Reinstate merchant'}
/>
</HStack>
</VStack>

View File

@@ -0,0 +1,341 @@
'use client';
import {useMemo, useState} from 'react';
import {Card} from '@astryxdesign/core/Card';
import {Grid} from '@astryxdesign/core/Grid';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Token} from '@astryxdesign/core/Token';
import {EmptyState} from '@astryxdesign/core/EmptyState';
import {Selector} from '@astryxdesign/core/Selector';
import {Pagination} from '@astryxdesign/core/Pagination';
import type {TableColumn} from '@astryxdesign/core/Table';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {
SkeletonChart,
SkeletonRows,
} from '@/shared/components/patterns/LoadingState';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {CHART, dashFor, defaultEncoding} from '@/shared/components/charts/palette';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {ICONS} from '@/shared/utils/icons';
import type {IconKey} from '@/shared/utils/icons';
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
/**
* The analytics layer's frame. Every chart and table on Footfall and Commerce
* goes through AnalyticsPanel, which is what keeps the four states distinct:
*
* capability off → ChartEmpty "pending": the chart's own frame, its icon,
* and "Backend integration required". No request is sent,
* so this is never shown while something is loading.
* loading → a skeleton at the chart's exact height (no layout jump)
* request failed → AsyncBoundary's error banner ("Couldn't load this" + the
* server's message) — an outage, not a missing feature
* loaded, empty → ChartEmpty "empty": "No data for the selected filters"
* loaded → children(data)
*
* Nothing in this file produces a value. A chart with no data draws no bars.
*/
export function AnalyticsPanel<T>({
title,
subtitle,
icon,
data,
pendingTitle,
height = 260,
variant = 'chart',
actions,
children,
}: {
title: string;
subtitle?: string;
icon: IconKey;
data: AdminData<T>;
/** e.g. "No area footfall data yet". */
pendingTitle: string;
height?: number;
variant?: 'chart' | 'table';
actions?: React.ReactNode;
children: (data: T) => React.ReactNode;
}) {
return (
<Card padding={5} className="h-full">
<VStack gap={4} width="100%">
<SectionHeader
title={title}
subtitle={subtitle}
actions={
data.isAvailable ? actions : (
<Token size="sm" label="Backend integration required" />
)
}
/>
{data.isAvailable ? (
<AsyncBoundary
resource={data.resource}
loading={
variant === 'table' ? (
<SkeletonRows count={5} />
) : (
<SkeletonChart height={height} />
)
}
empty={
<ChartEmpty
icon={icon}
height={height}
title="No data for the selected filters"
description="Try a wider date range, or a different merchant, area or shop."
/>
}
>
{children}
</AsyncBoundary>
) : (
<ChartEmpty
icon={icon}
height={height}
title={pendingTitle}
description="The chart is ready. It fills in as soon as the platform exposes this admin endpoint."
/>
)}
</VStack>
</Card>
);
}
/**
* The chart's footprint, held open. A dashed frame at the plot's own height
* says "a chart lives here" without drawing a single fake mark.
*/
export function ChartEmpty({
icon,
height,
title,
description,
}: {
icon: IconKey;
height: number;
title: string;
description: string;
}) {
return (
<VStack
width="100%"
minHeight={height}
hAlign="center"
vAlign="center"
className="rounded-lg border border-dashed border-border bg-muted"
>
<EmptyState
icon={<Icon icon={ICONS[icon]} size="lg" color="secondary" />}
title={title}
description={description}
isCompact
/>
</VStack>
);
}
/**
* Series identity for charts with 2+ lines. Each swatch is the series' actual
* stroke — same grey, same dash — so identity is never colour alone.
*/
export function ChartLegend({labels}: {labels: string[]}) {
return (
<HStack gap={4} vAlign="center" className="flex-wrap gap-y-1">
{labels.map((label, i) => (
<HStack key={label} gap={1.5} vAlign="center">
<svg width="20" height="8" aria-hidden="true">
<line
x1="0"
y1="4"
x2="20"
y2="4"
stroke={CHART.seriesAt(i)}
strokeWidth={2}
strokeDasharray={dashFor(defaultEncoding(i))}
/>
</svg>
<Text size="xsm" color="secondary">
{label}
</Text>
</HStack>
))}
</HStack>
);
}
/* ── Overview stat tiles ──────────────────────────────────────────────── */
export interface Stat {
label: string;
icon: IconKey;
/** `undefined` renders "—" with "Data unavailable" — never a zero. */
value?: string;
caption?: string;
}
/**
* A KPI row whose values can be absent. When the endpoint is off every tile
* shows "—" / "Data unavailable": the row keeps its shape, and a zero never
* stands in for "not measured".
*/
export function StatPanel<T>({
title,
subtitle,
data,
toStats,
placeholders,
}: {
title: string;
subtitle?: string;
data: AdminData<T>;
toStats: (d: T) => Stat[];
/** The same tiles with no values — shown while the endpoint is off. */
placeholders: Stat[];
}) {
return (
<Card padding={5}>
<VStack gap={4} width="100%">
<SectionHeader
title={title}
subtitle={subtitle}
actions={
data.isAvailable ? undefined : (
<Token size="sm" label="Backend integration required" />
)
}
/>
{data.isAvailable ? (
<AsyncBoundary
resource={data.resource}
loading={<StatGrid stats={placeholders} isLoading />}
>
{(d) => <StatGrid stats={toStats(d)} />}
</AsyncBoundary>
) : (
<StatGrid stats={placeholders} />
)}
</VStack>
</Card>
);
}
function StatGrid({stats, isLoading}: {stats: Stat[]; isLoading?: boolean}) {
const bp = useBreakpoint();
const columns =
bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : Math.min(stats.length, 5);
return (
<Grid columns={columns} gap={3}>
{stats.map((s) => (
<VStack key={s.label} gap={2} className="rounded-lg bg-muted p-4 min-w-0">
<HStack hAlign="between" vAlign="center" width="100%">
<Text size="xsm" color="secondary" weight="medium" className="truncate">
{s.label}
</Text>
<Icon icon={ICONS[s.icon]} size="sm" color="secondary" />
</HStack>
<Text size="xl" weight="semibold" className="tracking-tight">
{isLoading ? '…' : (s.value ?? '—')}
</Text>
<Text size="xsm" color="secondary" className="truncate">
{isLoading
? 'Loading'
: s.value === undefined
? 'Data unavailable'
: (s.caption ?? '')}
</Text>
</VStack>
))}
</Grid>
);
}
/* ── Details table ────────────────────────────────────────────────────── */
export interface SortOption<R> {
value: string;
label: string;
compare: (a: R, b: R) => number;
}
const PAGE_SIZE = 10;
/**
* Sorted and paged on the client, over exactly the rows the API returned.
* Columns whose field is absent from every row are dropped by the caller —
* a table never shows an "Entries" column the platform does not send.
*/
export function DetailsTable<R extends Record<string, unknown>>({
rows,
columns,
idKey,
primaryKey,
sorts,
}: {
rows: R[];
columns: TableColumn<R>[];
idKey: keyof R & string;
primaryKey: string;
sorts: SortOption<R>[];
}) {
const [sort, setSort] = useState(sorts[0].value);
const [page, setPage] = useState(1);
const sorted = useMemo(() => {
const cmp = sorts.find((s) => s.value === sort)?.compare;
return cmp ? [...rows].sort(cmp) : rows;
}, [rows, sorts, sort]);
const pageRows = sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<VStack gap={3} width="100%">
<HStack hAlign="between" vAlign="center" className="flex-wrap gap-y-2">
<Text size="sm" color="secondary">
{rows.length.toLocaleString()} {rows.length === 1 ? 'row' : 'rows'}
</Text>
<Selector
label="Sort rows"
isLabelHidden
size="sm"
options={sorts.map(({value, label}) => ({value, label}))}
value={sort}
onChange={(v) => {
setSort(v);
setPage(1);
}}
/>
</HStack>
<ResponsiveTable
data={pageRows}
columns={columns}
idKey={idKey}
primaryKey={primaryKey}
density="balanced"
/>
{rows.length > PAGE_SIZE ? (
<HStack hAlign="end">
<Pagination
page={page}
onChange={setPage}
totalItems={rows.length}
pageSize={PAGE_SIZE}
variant="count"
size="sm"
/>
</HStack>
) : null}
</VStack>
);
}
/** True when at least one row carries the field — the column earns its place. */
export function hasField<R>(rows: R[], key: keyof R): boolean {
return rows.some((r) => r[key] !== undefined && r[key] !== null);
}

View File

@@ -0,0 +1,153 @@
'use client';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import type {TableColumn} from '@astryxdesign/core/Table';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {
formatCount,
formatDayLabel,
formatInr,
formatInrCompact,
} from '@/shared/utils/format';
import type {IconKey} from '@/shared/utils/icons';
import type {AnalyticsFilters, CommerceDetail} from '@/features/admin/types/analytics';
import {useCommerceAnalytics} from '@/features/admin/hooks/useAnalytics';
import {
AnalyticsPanel,
DetailsTable,
StatPanel,
hasField,
type SortOption,
} from './AnalyticsPanel';
/**
* Commerce: overview → merchant comparison + sales trend → transactions.
*
* One measure per chart. Sales (₹) and transactions (a count) never share an
* axis: the trend plots sales, and the transaction count lives in the overview
* and the table rather than as a second y-scale.
*/
export function CommerceDashboard({filters}: {filters: AnalyticsFilters}) {
const bp = useBreakpoint();
const data = useCommerceAnalytics(filters);
const twoUp = bp === 'desktop' || bp === 'ultrawide';
return (
<VStack gap={6} width="100%">
<StatPanel
title="Commerce overview"
subtitle="Sales for the selected merchant, shop and period."
data={data.overview}
placeholders={OVERVIEW_TILES}
toStats={(o) => [
{...OVERVIEW_TILES[0], value: formatInrCompact(o.salesInr), caption: formatInr(o.salesInr)},
{...OVERVIEW_TILES[1], value: formatCount(o.transactions), caption: 'Recorded sales'},
{...OVERVIEW_TILES[2], value: formatInr(o.averageTransactionInr), caption: 'Per transaction'},
{...OVERVIEW_TILES[3], value: formatCount(o.activeMerchants), caption: 'With sales in the period'},
{...OVERVIEW_TILES[4], value: formatCount(o.activeShops), caption: 'With sales in the period'},
]}
/>
<Grid columns={twoUp ? 2 : 1} gap={6}>
<AnalyticsPanel
title="Sales by merchant"
subtitle="Compare merchants for the selected period."
icon="analytics"
data={data.byMerchant}
pendingTitle="No merchant sales data yet"
>
{(rows) => (
<BarChartView
data={[...rows].sort((a, b) => b.salesInr - a.salesInr)}
xKey="merchantName"
series={[{key: 'salesInr', label: 'Sales', format: formatInr}]}
yFormat={formatInrCompact}
/>
)}
</AnalyticsPanel>
<AnalyticsPanel
title="Sales over time"
subtitle="Daily sales across the selected range."
icon="revenue"
data={data.daily}
pendingTitle="No daily sales data yet"
>
{(rows) => (
<LineChartView
data={rows}
xKey="date"
series={[{key: 'salesInr', label: 'Sales', format: formatInr}]}
xFormat={formatDayLabel}
yFormat={formatInrCompact}
/>
)}
</AnalyticsPanel>
</Grid>
<AnalyticsPanel
title="Transactions"
subtitle="Sales by shop and day, with payment details where the platform records them."
icon="purchases"
data={data.details}
pendingTitle="No transaction rows yet"
variant="table"
height={220}
>
{(rows) => <CommerceTable rows={rows} />}
</AnalyticsPanel>
</VStack>
);
}
const OVERVIEW_TILES = [
{label: 'Total sales', icon: 'revenue'},
{label: 'Transactions', icon: 'purchases'},
{label: 'Avg transaction', icon: 'conversion'},
{label: 'Active merchants', icon: 'companies'},
{label: 'Active shops', icon: 'stores'},
] as const satisfies {label: string; icon: IconKey}[];
type CommerceRow = CommerceDetail & Record<string, unknown> & {rowId: string};
const COMMERCE_SORTS: SortOption<CommerceRow>[] = [
{value: 'date-desc', label: 'Newest first', compare: (a, b) => b.date.localeCompare(a.date)},
{value: 'date-asc', label: 'Oldest first', compare: (a, b) => a.date.localeCompare(b.date)},
{value: 'sales-desc', label: 'Highest sales', compare: (a, b) => b.salesInr - a.salesInr},
{value: 'merchant', label: 'Merchant A–Z', compare: (a, b) => a.merchantName.localeCompare(b.merchantName)},
];
function CommerceTable({rows}: {rows: CommerceDetail[]}) {
const data: CommerceRow[] = rows.map((r) => ({
...r,
rowId: `${r.date}|${r.shopId}|${r.paymentMethod ?? ''}|${r.category ?? ''}`,
}));
const columns: TableColumn<CommerceRow>[] = [
{key: 'date', header: 'Date', renderCell: (r) => <Text size="sm">{formatDayLabel(r.date)}</Text>},
{key: 'merchantName', header: 'Merchant'},
{key: 'shopName', header: 'Shop'},
{key: 'salesInr', header: 'Sales', align: 'end', renderCell: (r) => <Text size="sm">{formatInr(r.salesInr)}</Text>},
{key: 'transactions', header: 'Transactions', align: 'end', renderCell: (r) => <Text size="sm">{formatCount(r.transactions)}</Text>},
...(hasField(rows, 'paymentMethod')
? [{key: 'paymentMethod', header: 'Payment'} as TableColumn<CommerceRow>]
: []),
...(hasField(rows, 'category')
? [{key: 'category', header: 'Category'} as TableColumn<CommerceRow>]
: []),
];
return (
<DetailsTable
rows={data}
columns={columns}
idKey="rowId"
primaryKey="shopName"
sorts={COMMERCE_SORTS}
/>
);
}

View File

@@ -0,0 +1,215 @@
'use client';
import {useMemo} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import type {TableColumn} from '@astryxdesign/core/Table';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {formatCount, formatDayLabel} from '@/shared/utils/format';
import type {IconKey} from '@/shared/utils/icons';
import type {AnalyticsFilters, AreaDateFootfall, FootfallDetail} from '@/features/admin/types/analytics';
import {useFootfallAnalytics} from '@/features/admin/hooks/useAnalytics';
import {
AnalyticsPanel,
ChartLegend,
DetailsTable,
StatPanel,
hasField,
type SortOption,
} from './AnalyticsPanel';
/**
* Footfall: overview → area comparison + daily trend → area × date → details.
*
* Every panel reads its own endpoint through footfallRepository with the same
* filters, so a merchant / area / shop / range change refetches all of them
* and none can show another scope's rows.
*/
export function FootfallDashboard({filters}: {filters: AnalyticsFilters}) {
const bp = useBreakpoint();
const data = useFootfallAnalytics(filters);
const twoUp = bp === 'desktop' || bp === 'ultrawide';
return (
<VStack gap={6} width="100%">
<StatPanel
title="Footfall overview"
subtitle="Walk-ins for the selected merchant, area, shop and period."
data={data.overview}
placeholders={OVERVIEW_TILES}
toStats={(o) => [
{...OVERVIEW_TILES[0], value: formatCount(o.totalFootfall), caption: 'Walk-ins in the period'},
{...OVERVIEW_TILES[1], value: formatCount(Math.round(o.averageDaily)), caption: 'Per day'},
{
...OVERVIEW_TILES[2],
value: o.peakDay ? formatCount(o.peakDay.footfall) : undefined,
caption: o.peakDay ? formatDayLabel(o.peakDay.date) : undefined,
},
{...OVERVIEW_TILES[3], value: formatCount(o.activeLocations), caption: 'Areas with walk-ins'},
{...OVERVIEW_TILES[4], value: formatCount(o.reportingShops), caption: 'Shops that sent data'},
]}
/>
<Grid columns={twoUp ? 2 : 1} gap={6}>
<AnalyticsPanel
title="Footfall by area"
subtitle="Compare footfall across locations for the selected period."
icon="analytics"
data={data.byArea}
pendingTitle="No area footfall data yet"
>
{(rows) => (
<BarChartView
data={[...rows].sort((a, b) => b.footfall - a.footfall)}
xKey="area"
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
yFormat={formatCount}
/>
)}
</AnalyticsPanel>
<AnalyticsPanel
title="Daily footfall trend"
subtitle="Walk-ins per day across the selected range."
icon="visitors"
data={data.daily}
pendingTitle="No daily footfall data yet"
>
{(rows) => (
<AreaChartView
data={rows}
xKey="date"
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
xFormat={formatDayLabel}
yFormat={formatCount}
/>
)}
</AnalyticsPanel>
</Grid>
<AnalyticsPanel
title="Area × date"
subtitle="Every area across the same dates. The four busiest areas are drawn; the rest fold into Other."
icon="compare"
data={data.areaDate}
pendingTitle="No area trend data yet"
height={300}
>
{(rows) => <AreaDateChart rows={rows} />}
</AnalyticsPanel>
<AnalyticsPanel
title="Footfall details"
subtitle="One row per shop per day, as the platform reports it."
icon="events"
data={data.details}
pendingTitle="No footfall rows yet"
variant="table"
height={220}
>
{(rows) => <FootfallTable rows={rows} />}
</AnalyticsPanel>
</VStack>
);
}
const OVERVIEW_TILES = [
{label: 'Total footfall', icon: 'visitors'},
{label: 'Average daily', icon: 'analytics'},
{label: 'Peak day', icon: 'up'},
{label: 'Active locations', icon: 'stores'},
{label: 'Reporting shops', icon: 'present'},
] as const satisfies {label: string; icon: IconKey}[];
/* ── Area × date ──────────────────────────────────────────────────────── */
const MAX_SERIES = 4;
const OTHER = 'Other';
/**
* Pivots the long rows into one object per date with a key per area. Series
* follow the AREA, ranked by total — never cycled: past four, the rest sum
* into "Other" rather than reusing a grey another area already owns.
*/
function pivot(rows: AreaDateFootfall[]) {
const totals = new Map<string, number>();
for (const r of rows) totals.set(r.area, (totals.get(r.area) ?? 0) + r.footfall);
const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([a]) => a);
const shown = ranked.length > MAX_SERIES + 1 ? ranked.slice(0, MAX_SERIES) : ranked;
const folds = ranked.length > shown.length;
const byDate = new Map<string, Record<string, number | string>>();
for (const r of rows) {
const row = byDate.get(r.date) ?? {date: r.date};
const key = shown.includes(r.area) ? r.area : OTHER;
row[key] = ((row[key] as number | undefined) ?? 0) + r.footfall;
byDate.set(r.date, row);
}
const data = [...byDate.values()].sort((a, b) =>
String(a.date).localeCompare(String(b.date)),
);
return {data, series: folds ? [...shown, OTHER] : shown};
}
function AreaDateChart({rows}: {rows: AreaDateFootfall[]}) {
const {data, series} = useMemo(() => pivot(rows), [rows]);
return (
<VStack gap={3} width="100%">
<ChartLegend labels={series} />
<LineChartView
data={data}
xKey="date"
series={series.map((s) => ({key: s, label: s, format: formatCount}))}
xFormat={formatDayLabel}
yFormat={formatCount}
height={300}
/>
</VStack>
);
}
/* ── Details table ────────────────────────────────────────────────────── */
type FootfallRow = FootfallDetail & Record<string, unknown> & {rowId: string};
const FOOTFALL_SORTS: SortOption<FootfallRow>[] = [
{value: 'date-desc', label: 'Newest first', compare: (a, b) => b.date.localeCompare(a.date)},
{value: 'date-asc', label: 'Oldest first', compare: (a, b) => a.date.localeCompare(b.date)},
{value: 'footfall-desc', label: 'Most footfall', compare: (a, b) => b.footfall - a.footfall},
{value: 'shop', label: 'Shop A–Z', compare: (a, b) => a.shopName.localeCompare(b.shopName)},
];
function FootfallTable({rows}: {rows: FootfallDetail[]}) {
const data: FootfallRow[] = rows.map((r) => ({
...r,
rowId: `${r.date}|${r.shopId}`,
}));
const columns: TableColumn<FootfallRow>[] = [
{key: 'date', header: 'Date', renderCell: (r) => <Text size="sm">{formatDayLabel(r.date)}</Text>},
{key: 'merchantName', header: 'Merchant'},
...(hasField(rows, 'area') ? [{key: 'area', header: 'Area'} as TableColumn<FootfallRow>] : []),
{key: 'shopName', header: 'Shop'},
{key: 'footfall', header: 'Footfall', align: 'end', renderCell: (r) => <Text size="sm">{formatCount(r.footfall)}</Text>},
...(hasField(rows, 'entries')
? [{key: 'entries', header: 'Entries', align: 'end'} as TableColumn<FootfallRow>]
: []),
...(hasField(rows, 'exits')
? [{key: 'exits', header: 'Exits', align: 'end'} as TableColumn<FootfallRow>]
: []),
];
return (
<DetailsTable
rows={data}
columns={columns}
idKey="rowId"
primaryKey="shopName"
sorts={FOOTFALL_SORTS}
/>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { Breadcrumbs, BreadcrumbItem } from "@astryxdesign/core/Breadcrumbs";
import { HStack, VStack } from "@astryxdesign/core/Layout";
import { Text } from "@astryxdesign/core/Text";
import { Button } from "@astryxdesign/core/Button";
import { Icon } from "@astryxdesign/core/Icon";
export interface Crumb {
label: string;
/** Omit on the last crumb — it is the current page. */
href?: string;
}
/**
* Where the operator is, what this page is, and what they can do here.
*
* Breadcrumbs appear only when there is a parent: a top-level page with a
* one-item trail is clutter. Four levels deep (company / store / camera) the
* trail is the only thing telling an operator which tenant they are looking
* at, which on a console that can suspend it is not a nicety.
*/
export function AdminPageHeader({
title,
subtitle,
back,
crumbs,
meta,
actions,
}: {
title: string;
subtitle?: string;
/** One level up. A drill-down page needs a way back that is not the
* browser's — the operator may have arrived from a link. */
back?: { label: string; href: string };
crumbs?: Crumb[];
/** A row under the title — status, slug, dates. */
meta?: React.ReactNode;
actions?: React.ReactNode;
}) {
return (
<VStack gap={3} width="100%">
{back || (crumbs && crumbs.length > 0) ? (
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-1">
{back ? <BackButton {...back} /> : null}
{crumbs && crumbs.length > 0 ? (
<Breadcrumbs variant="supporting">
{crumbs.map((c, i) =>
i === crumbs.length - 1 ? (
<BreadcrumbItem key={i} isCurrent>
{c.label}
</BreadcrumbItem>
) : (
<BreadcrumbItem key={i} href={c.href}>
{c.label}
</BreadcrumbItem>
),
)}
</Breadcrumbs>
) : null}
</HStack>
) : null}
<HStack
gap={4}
vAlign="start"
hAlign="between"
width="100%"
className="flex-wrap gap-y-3"
>
<VStack gap={1} className="min-w-0">
<Text type="display-3" weight="medium" className="break-words">
{title}
</Text>
{subtitle ? (
<Text size="sm" color="secondary">
{subtitle}
</Text>
) : null}
{meta ? (
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-1">
{meta}
</HStack>
) : null}
</VStack>
{actions ? (
<HStack gap={2} vAlign="center">
{actions}
</HStack>
) : null}
</HStack>
</VStack>
);
}
export function BackButton({ label, href }: { label: string; href: string }) {
return (
<Button
variant="ghost"
size="sm"
href={href}
label={label}
icon={<Icon icon="chevronLeft" size="sm" />}
/>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack} from '@astryxdesign/core/Layout';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
import type {IconKey} from '@/shared/utils/icons';
import {PendingIntegration} from './PendingIntegration';
/**
* A panel over one level of the drill-down, in whichever of its three states
* it is in:
*
* unavailable no admin endpoint → PendingIntegration, no request sent
* loading / error / empty → AsyncBoundary, as everywhere else
* loaded → `children(data)`
*
* This is what makes "turn the level on" a flag flip: the section already
* renders the loaded branch; only the hook's answer changes.
*/
export function AdminSection<T>({
title,
subtitle,
actions,
data,
pending,
loading,
empty,
children,
}: {
title: string;
subtitle?: string;
actions?: React.ReactNode;
data: AdminData<T>;
pending: {icon: IconKey; title: string; description: string};
loading: React.ReactNode;
empty?: React.ReactNode;
children: (data: T) => React.ReactNode;
}) {
return (
<Card>
<VStack gap={4}>
<SectionHeader
title={title}
subtitle={subtitle}
actions={data.isAvailable ? actions : undefined}
/>
{data.isAvailable ? (
<AsyncBoundary resource={data.resource} loading={loading} empty={empty}>
{children}
</AsyncBoundary>
) : (
<PendingIntegration {...pending} />
)}
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import {EmptyState} from '@astryxdesign/core/EmptyState';
import {Icon} from '@astryxdesign/core/Icon';
import {Token} from '@astryxdesign/core/Token';
import {ICONS} from '@/shared/utils/icons';
import type {IconKey} from '@/shared/utils/icons';
/**
* "The platform cannot answer this yet" — the one treatment for every level
* of the drill-down that has no admin endpoint behind it.
*
* Calm, not alarming: no red, no Banner. Nothing is broken; a capability is
* missing, and the token says exactly which kind of missing it is so nobody
* mistakes it for "this company has no stores". That distinction is the whole
* point — an empty list and an unanswerable question look identical if you
* render them the same way, and an operator would act on the wrong one.
*
* Unlike the merchant console's FeatureUnavailable, this names the gap in
* engineering terms. The audience here is the platform team, who are the
* people who can close it.
*/
export function PendingIntegration({
icon,
title,
description,
}: {
icon: IconKey;
title: string;
description: string;
}) {
return (
<EmptyState
icon={<Icon icon={ICONS[icon]} size="lg" color="secondary" />}
title={title}
description={description}
actions={<Token label="Backend integration required" size="sm" />}
isCompact
/>
);
}

View File

@@ -0,0 +1,274 @@
'use client';
import {useEffect} from 'react';
import {usePathname, useRouter} from 'next/navigation';
import {AppShell} from '@astryxdesign/core/AppShell';
import {
Layout,
LayoutContent,
LayoutPanel,
HStack,
VStack,
} from '@astryxdesign/core/Layout';
import {TopNav, TopNavItem} from '@astryxdesign/core/TopNav';
import {MobileNav} from '@astryxdesign/core/MobileNav';
import {SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Token} from '@astryxdesign/core/Token';
import {Link} from '@astryxdesign/core/Link';
import {BrandLogo, BrandMark} from '@/shared/components/brand/BrandLogo';
import {NavMenuButton} from '@/shared/layouts/workspace/NavMenuButton';
import {
NAV_DRAWER_ID,
SidebarProvider,
useSidebar,
} from '@/shared/layouts/workspace/SidebarProvider';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {LoyalyAiPanel} from '@/features/loyaly-ai/components/LoyalyAiPanel';
import {LoyalyAiSlideOver} from '@/features/loyaly-ai/components/LoyalyAiSlideOver';
import {LoyalyAiToggle} from '@/features/loyaly-ai/components/LoyalyAiToggle';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {
useBreakpoint,
isPanelInline,
assistantWidth,
contentMaxWidth,
} from '@/shared/hooks/useBreakpoint';
import {ADMIN_NAV, isAdminNavActive} from './admin-nav';
/**
* The platform console's frame: one top bar, content, Loyaly AI.
*
* No sidebar. The console has three destinations, which fit a horizontal bar
* and leave the full width to the tables — the thing an operator is actually
* scanning. Loyaly AI keeps its column on the right, as in the workspace, via
* a nested Layout's `end` panel (AppShell has no slot for it).
*
* Responsive behaviour, by breakpoint:
* mobile tabs → drawer behind the menu button (TopNav's mobile-bar mode
* drops `startContent`, so the tabs cannot simply shrink),
* Loyaly AI → full-height sheet
* tablet tabs inline, Loyaly AI → slide-over
* laptop+ tabs inline, Loyaly AI inline panel
*
* SidebarProvider is kept only for the drawer's open state — the same state
* NavMenuButton drives — so the mobile menu behaves exactly like the
* workspace's.
*/
export function AdminShell({children}: {children: React.ReactNode}) {
return (
<SidebarProvider>
<ShellFrame>{children}</ShellFrame>
</SidebarProvider>
);
}
function ShellFrame({children}: {children: React.ReactNode}) {
const bp = useBreakpoint();
const {isOpen, setIsOpen, panelMode, panelWidth} = useLoyalyAi();
const {isDrawerOpen, setDrawerOpen} = useSidebar();
const inline = isPanelInline(bp);
const showInlinePanel = inline && isOpen;
const isFullscreen = showInlinePanel && panelMode === 'fullscreen';
let width: number | string = assistantWidth(bp);
if (showInlinePanel) {
if (isFullscreen) width = '100%';
else if (panelMode === 'expanded') width = Math.max(panelWidth, 800);
else width = panelWidth;
}
return (
<AppShell
// `wash`, not `elevated`: elevated paints the content column white
// (--color-background-surface), which hid the ambient canvas everywhere
// except the top bar. Wash gives the bar and content the same ground, so
// the gradient in globals.css runs across the full page — as loyaly-staff.
variant="wash"
height="fill"
contentPadding={0}
topNav={<AdminTopNav />}
mobileNav={{
breakpoint: 'sm',
isOpen: isDrawerOpen,
onOpenChange: setDrawerOpen,
hasToggle: false,
content: <AdminMobileMenu />,
}}
>
<Layout
height="fill"
content={
!isFullscreen ? (
<LayoutContent padding={5}>
<VStack
width="100%"
maxWidth={contentMaxWidth(bp)}
className={bp === 'ultrawide' ? 'mx-auto' : undefined}
>
{children}
</VStack>
</LayoutContent>
) : undefined
}
end={
showInlinePanel ? (
<LayoutPanel
hasDivider
width={width as number}
padding={0}
role="complementary"
label="Loyaly AI"
className="transition-[width] duration-300 ease-out"
>
<LoyalyAiPanel onClose={() => setIsOpen(false)} />
</LayoutPanel>
) : undefined
}
/>
{!inline ? <LoyalyAiSlideOver /> : null}
</AppShell>
);
}
/**
* The selected tab reads as an underline, not a filled pill: a 2px bar in the
* primary text colour along the bottom edge. Token-backed utilities only —
* `border-b-2` is the spacing scale's 2px and `border-primary` the text token,
* so the bar stays monochrome in both themes.
*/
const TAB = 'rounded-none border-b-2 border-transparent';
const TAB_SELECTED = 'rounded-none border-b-2 border-primary bg-transparent';
function AdminTopNav() {
const pathname = usePathname();
const {isDrawer} = useSidebar();
return (
<TopNav
label="Platform admin"
heading={
<HStack gap={3} vAlign="center">
{isDrawer ? <NavMenuButton /> : null}
<Link href="/admin">
{isDrawer ? <BrandMark size={26} /> : <BrandLogo height={26} />}
</Link>
{/* Said loudly on purpose: this console can suspend and delete a
company, and somebody with both consoles open needs to know
which tab they are in before they click. */}
<Token label="Admin" size="sm" />
</HStack>
}
startContent={
<HStack gap={1} vAlign="center" className="self-stretch">
{ADMIN_NAV.map((item) => {
const selected = isAdminNavActive(pathname, item.href);
return (
<TopNavItem
key={item.href}
label={item.label}
href={item.href}
isSelected={selected}
className={selected ? TAB_SELECTED : TAB}
/>
);
})}
</HStack>
}
endContent={
<HStack gap={2} vAlign="center">
<LoyalyAiToggle />
<AccountMenu />
</HStack>
}
/>
);
}
/**
* Who is signed in, and the way out.
*
* Not the workspace AccountMenu: every row in that one routes into a tenant's
* settings, and this account has no tenant. The EMAIL heads the menu rather
* than the display name — on a console that can delete a company, which
* account is signed in matters more than whose it is.
*/
function AccountMenu() {
const {user, logout} = useSession();
const router = useRouter();
const who = user?.email ?? 'Platform admin';
async function signOut() {
await logout();
router.replace('/login');
}
return (
<DropdownMenu
button={{
variant: 'ghost',
size: 'md',
label: `Account: ${who}`,
isIconOnly: true,
icon: <Avatar name={user?.name || who} size="sm" tooltip={false} />,
}}
hasChevron={false}
menuWidth={260}
items={[
{
type: 'section',
title: who,
items: [{label: 'Platform admin', isDisabled: true}],
},
{type: 'divider'},
{label: 'Profile', onClick: () => router.push('/admin/profile')},
{label: 'Settings', onClick: () => router.push('/admin/settings')},
{type: 'divider'},
{label: 'Sign out', onClick: () => void signOut()},
]}
/>
);
}
/** The tabs again, as a drawer, for the width where TopNav drops them. */
const DRAWER = [
'[&>div]:w-4/5',
'[&>div>*:first-child>button]:size-11',
'[&_.astryx-side-nav-item]:h-12',
].join(' ');
function AdminMobileMenu() {
const pathname = usePathname();
const {isDrawerOpen, setDrawerOpen, closeDrawer} = useSidebar();
useEffect(() => {
closeDrawer();
}, [pathname, closeDrawer]);
return (
<MobileNav
id={NAV_DRAWER_ID}
label="Navigation"
side="start"
width={320}
isOpen={isDrawerOpen}
onOpenChange={setDrawerOpen}
className={DRAWER}
header={<BrandLogo height={26} />}
>
<SideNavSection title="Platform admin" isHeaderHidden>
{ADMIN_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
href={item.href}
isSelected={isAdminNavActive(pathname, item.href)}
onClick={closeDrawer}
/>
))}
</SideNavSection>
</MobileNav>
);
}

View File

@@ -0,0 +1,36 @@
/**
* The platform console's top navigation.
*
* Platform-level destinations only. Everything that belongs to one merchant —
* shops, sales persons, cameras, customers, sales, analytics, device logs —
* lives inside that merchant (merchants → merchant → shop → camera), never
* here, so no global tab can mix two merchants' data.
*
* Footfall and Commerce are platform views that still resolve Merchant → Shop
* before they ask for anything; see PlatformPages.
*
* Add a tab here, with its route, when the platform can answer it — TopNav
* reads best at five or fewer.
*/
export interface AdminNavEntry {
label: string;
href: string;
}
export const ADMIN_NAV: AdminNavEntry[] = [
{label: 'Overview', href: '/admin'},
{label: 'Merchants', href: '/admin/merchants'},
{label: 'Footfall', href: '/admin/footfall'},
{label: 'Commerce', href: '/admin/commerce'},
];
/**
* `/admin` is the overview and a prefix of every other entry, so it matches
* exactly; everything else lights up for its own detail pages too — a shop
* page under /admin/merchants/… keeps "Merchants" selected, because that is
* the path the operator took to get there.
*/
export function isAdminNavActive(pathname: string, href: string): boolean {
if (href === '/admin') return pathname === '/admin';
return pathname === href || pathname.startsWith(`${href}/`);
}