dashboard design changes
This commit is contained in:
@@ -3,7 +3,11 @@
|
||||
import {useState} from 'react';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||
import {ScopeControls} from '@/components/scope/ScopeControls';
|
||||
import {ICONS} from '@/lib/icons';
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {AreaChartView} from '@/components/charts/AreaChartView';
|
||||
import {LineChartView} from '@/components/charts/LineChartView';
|
||||
@@ -27,18 +31,51 @@ import {
|
||||
} from '@/lib/format';
|
||||
|
||||
/**
|
||||
* The dashboard answers three questions in order, top to bottom:
|
||||
* 1. How is the business performing? → KPI row
|
||||
* 2. What is the shape of that? → footfall / revenue / conversion
|
||||
* 3. What needs attention, and when? → peak hours, rewards, stores, feed
|
||||
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
|
||||
*
|
||||
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider,
|
||||
* so changing either control in the top nav re-queries the whole page without
|
||||
* a route change — which is what keeps the Copilot mounted alongside it.
|
||||
* The page reads top-down as one narrowing question: headline numbers → the two
|
||||
* trends that drive them → the conversion story behind those → when and on what
|
||||
* it happens → the period rollup → which store → what just happened.
|
||||
*
|
||||
* Operational widgets — quick actions, tasks, the AI briefing, top store/reward,
|
||||
* staff status — deliberately do NOT live here. The briefing endpoint they were
|
||||
* built on is still live and feeds the Copilot's AI tab, which is where that
|
||||
* class of content belongs: a panel you open to be told what to do, beside a
|
||||
* dashboard you read to work it out yourself.
|
||||
*
|
||||
* Recent activity keeps its compact form — six rows in a fixed box, full history
|
||||
* on /activity. The uncapped version reached 784px, taller than any chart on the
|
||||
* page, which is an operational log outweighing the analytics it sits among.
|
||||
*
|
||||
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider, set
|
||||
* from this page's header. `series` is fetched once and shared by the four
|
||||
* charts drawn from it, so they cannot disagree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derived from the SERVER's clock, carried on the response meta.
|
||||
*
|
||||
* Calling Date.now() during render is impure and disagrees between the SSR and
|
||||
* hydration passes. Before meta lands there is no instant to reason about, so
|
||||
* the greeting is the time-independent one; both strings are a single line, so
|
||||
* the swap costs no layout shift. getHours() resolves the server's instant in
|
||||
* the viewer's zone, which is the hour the merchant is actually living in.
|
||||
*/
|
||||
function greetingFor(generatedAt?: string): string {
|
||||
if (!generatedAt) return 'Welcome back';
|
||||
const hour = new Date(generatedAt).getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 17) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const {storeId, range} = useWorkspace();
|
||||
const [granularity, setGranularity] = useState<Granularity>('weekly');
|
||||
// On by default: store comparison has always been part of the all-stores
|
||||
// dashboard, so Compare starts pressed and switches the panel off, rather
|
||||
// than hiding a panel that used to be there until someone finds the button.
|
||||
const [isComparing, setIsComparing] = useState(true);
|
||||
|
||||
const scope = {range, storeId};
|
||||
|
||||
@@ -49,17 +86,46 @@ export default function DashboardPage() {
|
||||
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
|
||||
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
|
||||
|
||||
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
|
||||
const isAllStores = storeId === 'all';
|
||||
const scopeLabel = isAllStores ? 'all stores' : storeName(storeId);
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
{/* 1 + 2 — greeting, title, and the store / period / compare filters. */}
|
||||
<PageHeader
|
||||
eyebrow={greetingFor(kpis.meta?.generatedAt)}
|
||||
title="Dashboard"
|
||||
description={`How the business is performing across ${scopeLabel}, what needs attention, and what to do next.`}
|
||||
description={`Business performance across ${scopeLabel}.`}
|
||||
controls={
|
||||
<>
|
||||
<ScopeControls />
|
||||
<Button
|
||||
size="sm"
|
||||
// Pressed state is the raised gray of `primary` in this theme —
|
||||
// a toggle needs a visible on-state, and it must not be colour.
|
||||
variant={isComparing ? 'primary' : 'secondary'}
|
||||
label="Compare"
|
||||
icon={<Icon icon={ICONS.compare} size="sm" />}
|
||||
aria-pressed={isComparing}
|
||||
isDisabled={!isAllStores}
|
||||
// With a tooltip present, Button uses aria-disabled rather than
|
||||
// the native attribute, so the reason stays reachable by keyboard
|
||||
// instead of the control just going dead.
|
||||
tooltip={
|
||||
isAllStores
|
||||
? undefined
|
||||
: 'Comparing stores needs the All stores scope'
|
||||
}
|
||||
onClick={() => setIsComparing((v) => !v)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 3 — headline numbers. */}
|
||||
<KpiRow resource={kpis} />
|
||||
|
||||
{/* 4 — primary analytics: the two series everything else explains. */}
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
|
||||
{(d) => (
|
||||
@@ -84,7 +150,10 @@ export default function DashboardPage() {
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
{/* 5 — secondary analytics: the conversion story, in two readings. */}
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard
|
||||
title="Visitors vs purchases"
|
||||
subtitle="The gap is the conversion opportunity"
|
||||
@@ -124,6 +193,7 @@ export default function DashboardPage() {
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
{/* 6 — operational analytics: when traffic lands, what it redeems. */}
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard
|
||||
title="Peak hours"
|
||||
@@ -137,18 +207,27 @@ export default function DashboardPage() {
|
||||
<RewardUsageChart resource={rewards} />
|
||||
</Grid>
|
||||
|
||||
{/* 7 — period rollup. */}
|
||||
<PerformancePanel
|
||||
scope={scope}
|
||||
granularity={granularity}
|
||||
onGranularityChange={setGranularity}
|
||||
/>
|
||||
|
||||
{/* Comparing stores is meaningless when scoped to one of them. */}
|
||||
{storeId === 'all' ? (
|
||||
{/* 8 — comparing stores is meaningless when scoped to one of them, which
|
||||
is why Compare is disabled rather than merely off in that case. */}
|
||||
{isAllStores && isComparing ? (
|
||||
<StoreComparisonPanel resource={comparison} />
|
||||
) : null}
|
||||
|
||||
<ActivityTimeline resource={activity} />
|
||||
{/* 9 — bounded feed: six rows, fixed box, full history on /activity. */}
|
||||
<ActivityTimeline
|
||||
resource={activity}
|
||||
subtitle="Latest events across the selected store and period"
|
||||
limit={6}
|
||||
height={285}
|
||||
viewAllHref="/activity"
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user