Files
loyaly-merchant/src/app/(workspace)/dashboard/page.tsx
2026-08-05 18:34:37 +05:30

234 lines
8.9 KiB
TypeScript

'use client';
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';
import {BarChartView} from '@/components/charts/BarChartView';
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
import {KpiRow} from '@/features/dashboard/KpiRow';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {RewardUsageChart} from '@/features/dashboard/RewardUsageChart';
import {StoreComparisonPanel} from '@/features/dashboard/StoreComparison';
import {PerformancePanel} from '@/features/dashboard/PerformancePanel';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {storeName} from '@/lib/mock/stores';
import type {Granularity} from '@/lib/api/contracts';
import {
formatCompact,
formatDayLabel,
formatInrCompact,
formatPct,
} from '@/lib/format';
/**
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
*
* 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};
const kpis = useResource(endpoints.dashboardKpis(scope));
const series = useResource(endpoints.dashboardTimeseries(scope));
const peak = useResource(endpoints.dashboardPeakHours(scope));
const activity = useResource(endpoints.dashboardActivity(scope));
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
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={`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) => (
<AreaChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[{key: 'visitors', label: 'Visitors'}]}
/>
)}
</ChartCard>
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
{(d) => (
<BarChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
/>
)}
</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"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[
{key: 'visitors', label: 'Visitors'},
{key: 'purchases', label: 'Purchases'},
]}
/>
)}
</ChartCard>
<ChartCard
title="Conversion"
subtitle="Share of visitors who bought"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={(v) => formatPct(v, 0)}
series={[{key: 'conversion', label: 'Conversion'}]}
// The only semantic colour on this chart: above the benchmark is
// good, below is not, and that is what the tint communicates.
reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
/>
)}
</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"
subtitle="Where the week's footfall actually lands"
resource={peak}
height={200}
>
{(d) => <HeatmapGrid data={d} />}
</ChartCard>
<RewardUsageChart resource={rewards} />
</Grid>
{/* 7 — period rollup. */}
<PerformancePanel
scope={scope}
granularity={granularity}
onGranularityChange={setGranularity}
/>
{/* 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}
{/* 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>
);
}